<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: u11d</title>
    <description>The latest articles on DEV Community by u11d (u11d).</description>
    <link>https://dev.to/u11d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F11016%2F3aaba768-bbba-47eb-999a-9c314cf428f1.png</url>
      <title>DEV Community: u11d</title>
      <link>https://dev.to/u11d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/u11d"/>
    <language>en</language>
    <item>
      <title>MinIO Is Deprecated: Best Alternatives for S3-Compatible Storage</title>
      <dc:creator>Bartek Gałęzowski</dc:creator>
      <pubDate>Mon, 28 Sep 2026 10:16:42 +0000</pubDate>
      <link>https://dev.to/u11d/minio-is-deprecated-best-alternatives-for-s3-compatible-storage-2g4j</link>
      <guid>https://dev.to/u11d/minio-is-deprecated-best-alternatives-for-s3-compatible-storage-2g4j</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;That recommendation has changed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That raises an obvious question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What should replace MinIO?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There isn't a single answer. Three projects are particularly worth looking at: &lt;strong&gt;Garage, RustFS, and SeaweedFS&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;The important thing is therefore not simply to find "the next MinIO", but to understand what each project is actually designed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened to MinIO?
&lt;/h2&gt;

&lt;p&gt;The MinIO repository on GitHub is now archived and read-only. Its current README states:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"THIS REPOSITORY IS NO LONGER MAINTAINED."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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 &lt;strong&gt;future lifecycle&lt;/strong&gt; of the installation.&lt;/p&gt;

&lt;p&gt;In September 2026, the &lt;code&gt;minio/minio&lt;/code&gt; repository on Docker Hub was archived, and users started reporting that both &lt;code&gt;minio/minio&lt;/code&gt; and &lt;code&gt;minio/mc&lt;/code&gt; 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'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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio/minio:latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the image is no longer available.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alternative #1: Garage
&lt;/h2&gt;

&lt;p&gt;Garage is a distributed object storage system written in Rust that implements the S3 API.&lt;/p&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For example, you could have infrastructure distributed across several sites:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    subgraph SiteA["Site A (Primary DC)"]
        A1["Node 1"] --- A2["Node 2"] --- A3["Node 3"]
    end

    subgraph SiteB["Site B (Secondary / Edge)"]
        B1["Node 4"] --- B2["Node 5"] --- B3["Node 6"]
    end

    SiteA &amp;lt;--&amp;gt;|Geo-distributed sync / WAN| SiteB&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;This is one of the areas where Garage differs significantly from the traditional MinIO mental model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alternative #2: RustFS
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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."&lt;/p&gt;

&lt;p&gt;RustFS uses a distributed model consisting of nodes and drives. Conceptually, it looks like:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    Client["S3 Client Traffic"] --&amp;gt; N3 &amp;amp; N2 &amp;amp; N1

    subgraph Cluster["RustFS Cluster (Erasure Set)"]
        subgraph N3["Node 3"]
            D5["Drive 5"]
            D6["Drive 6"]
        end
        subgraph N2["Node 2"]
            D3["Drive 3"]
            D4["Drive 4"]
        end
        subgraph N1["Node 1"]
            D1["Drive 1"]
            D2["Drive 2"]
        end
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The cluster is divided into sets, with drives distributed across nodes. For engineers coming from MinIO, the mental model is relatively familiar. This is what makes RustFS one of the most obvious projects to investigate if the main objective is replacing an existing MinIO installation while keeping the application layer largely unchanged.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alternative #3: SeaweedFS
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Conceptually, it looks like:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    subgraph Interfaces["Storage Interfaces"]
        S3["S3 API"]
        FUSE["FUSE"]
        HDFS["HDFS"]
    end

    subgraph Cluster["SeaweedFS Cluster"]
        Storage["Storage Layer"]
    end

    Interfaces --&amp;gt; Cluster&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Garage vs RustFS vs SeaweedFS
&lt;/h2&gt;

&lt;p&gt;At this point, it should be clear that these three projects aren't simply three interchangeable MinIO clones.&lt;/p&gt;

&lt;p&gt;They all provide S3-compatible storage, but they target somewhat different requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Garage&lt;/strong&gt; is focused on decentralized object storage and is particularly interesting for geographically distributed or multi-site deployments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RustFS&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SeaweedFS&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;The important distinction is therefore not simply performance or feature count. It is the architecture you want to operate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Garage&lt;/th&gt;
&lt;th&gt;RustFS&lt;/th&gt;
&lt;th&gt;SeaweedFS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary language&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 compatibility&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Architecture&lt;/td&gt;
&lt;td&gt;Distributed, decentralized&lt;/td&gt;
&lt;td&gt;Distributed, decentralized&lt;/td&gt;
&lt;td&gt;Layered distributed storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Primary focus&lt;/td&gt;
&lt;td&gt;Distributed object storage&lt;/td&gt;
&lt;td&gt;S3 object storage&lt;/td&gt;
&lt;td&gt;Object + file storage platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MinIO migration&lt;/td&gt;
&lt;td&gt;Requires architectural changes&lt;/td&gt;
&lt;td&gt;Conceptually closer&lt;/td&gt;
&lt;td&gt;Requires architectural changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Geo-distribution&lt;/td&gt;
&lt;td&gt;Strong focus&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filesystem access&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FUSE&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HDFS&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small-object workloads&lt;/td&gt;
&lt;td&gt;Good fit&lt;/td&gt;
&lt;td&gt;Good fit&lt;/td&gt;
&lt;td&gt;Strong focus&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Erasure coding&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational complexity&lt;/td&gt;
&lt;td&gt;Relatively focused&lt;/td&gt;
&lt;td&gt;Relatively focused&lt;/td&gt;
&lt;td&gt;Higher&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best suited for&lt;/td&gt;
&lt;td&gt;Multi-site / decentralized storage&lt;/td&gt;
&lt;td&gt;MinIO replacement&lt;/td&gt;
&lt;td&gt;Multi-purpose distributed storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration complexity&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Low–medium&lt;/td&gt;
&lt;td&gt;Medium–high&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you have multiple locations and want decentralized storage that treats geographic distribution as a fundamental part of the architecture, Garage is worth investigating.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you need more than S3 — for example, filesystem access, HDFS, huge numbers of small objects, or multiple storage interfaces — SeaweedFS deserves serious consideration.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;For an existing MinIO deployment, the decision should not start with &lt;strong&gt;"Which project is the best?"&lt;/strong&gt;. It should start with &lt;strong&gt;"What do we actually need from our storage layer?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>minio</category>
      <category>s3</category>
      <category>rustfs</category>
      <category>garage</category>
    </item>
    <item>
      <title>TypeSafe AI Jev explained: examples, use cases, and LLM comparisons</title>
      <dc:creator>Paweł Swiridow</dc:creator>
      <pubDate>Wed, 23 Sep 2026 19:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/typesafe-ai-jev-explained-examples-use-cases-and-llm-comparisons-2k4o</link>
      <guid>https://dev.to/u11d/typesafe-ai-jev-explained-examples-use-cases-and-llm-comparisons-2k4o</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick answer
&lt;/h2&gt;

&lt;p&gt;Use Jev when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every valid answer can be defined before the call.&lt;/li&gt;
&lt;li&gt;The hard part is semantic judgment, not arithmetic or deterministic policy.&lt;/li&gt;
&lt;li&gt;Software needs probabilities or confidence to decide whether to act, retry, or escalate.&lt;/li&gt;
&lt;li&gt;The workload repeats often enough for latency and per-call cost to matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use something else when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The output must contain newly written text, code, or an explanation.&lt;/li&gt;
&lt;li&gt;Correctness depends on exact math, dates, permissions, or database state.&lt;/li&gt;
&lt;li&gt;The task requires long, multi-step reasoning.&lt;/li&gt;
&lt;li&gt;The model would be the only security or authorization boundary.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is TypeSafe AI Jev?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The contract is simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;state + typed questions -&amp;gt; Jev -&amp;gt; typed answers + probabilities -&amp;gt; application code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;At the time of writing, &lt;code&gt;jev-latest&lt;/code&gt; resolves to &lt;code&gt;jev-1.13.0&lt;/code&gt;. TypeSafe's &lt;a href="https://docs.typesafe.ai/models" rel="noopener noreferrer"&gt;model documentation&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;TypeSafe's &lt;a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev" rel="noopener noreferrer"&gt;launch material&lt;/a&gt; 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. &lt;a href="https://arize.com/blog/typesafe-jev-llm-judge/" rel="noopener noreferrer"&gt;Independent analysis from Arize&lt;/a&gt; makes the same distinction.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Choice, Score, and Noul work
&lt;/h2&gt;

&lt;p&gt;Jev exposes three question types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Primitive&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;th&gt;Returned data&lt;/th&gt;
&lt;th&gt;Good fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Choice&lt;/td&gt;
&lt;td&gt;Which predefined option fits best?&lt;/td&gt;
&lt;td&gt;selected option, option probabilities, confidence&lt;/td&gt;
&lt;td&gt;intent routing, taxonomy classification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Score&lt;/td&gt;
&lt;td&gt;Where does this fall on an ordered rubric?&lt;/td&gt;
&lt;td&gt;weighted score, level probabilities, legend, confidence&lt;/td&gt;
&lt;td&gt;severity, quality, urgency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Noul&lt;/td&gt;
&lt;td&gt;How likely is this yes/no proposition?&lt;/td&gt;
&lt;td&gt;probability from 0 to 1&lt;/td&gt;
&lt;td&gt;detection, verification, binary review signals&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A Choice can contain up to 255 options. A Score accepts between two and ten ordered levels. Choice and Score return a &lt;code&gt;confidence&lt;/code&gt; value derived from their probability distributions; Noul returns the probability of yes without a separate confidence field.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run a Jev decision in Python
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Install the SDK
&lt;/h3&gt;

&lt;p&gt;The Python SDK requires an API key in &lt;code&gt;TYPESAFE_API_KEY&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install &lt;/span&gt;typesafe-sdk
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TYPESAFE_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'replace-with-a-key-from-console.typesafe.ai'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use your normal secret manager in CI and production. Committing the key beside the example would turn a quick start into an incident report.&lt;/p&gt;

&lt;h3&gt;
  
  
  Classify and route a support ticket
&lt;/h3&gt;

&lt;p&gt;This example asks one Choice, one Score, and one Noul in a single call. It then applies routing thresholds in ordinary Python.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typesafe_sdk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Choice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Noul&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TypeSafeClient&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TYPESAFE_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Set TYPESAFE_API_KEY before running this example&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;INPUT_USD_PER_MTOK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.042&lt;/span&gt;  &lt;span class="c1"&gt;# TypeSafe list price at the time of writing
&lt;/span&gt;
&lt;span class="n"&gt;ticket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Checkout fails after payment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Our card was charged twice and checkout still shows an error. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;We need this fixed today because customers cannot place orders.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;business&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;TypeSafeClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jev-1.13.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;system_one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;department&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Which team should own this ticket first?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;criteria&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Charges, invoices, refunds, or subscriptions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;technical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bugs, outages, failed integrations, or broken product behavior&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;account&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Login, identity, permissions, or account access&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;other&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The ticket does not fit the other categories&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;How severely does this issue block the customer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s work?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;criteria&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Minor inconvenience with a clear workaround&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Important feature is degraded but work can continue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Critical workflow is blocked or customers are losing money&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requests_refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Noul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Does the customer explicitly ask for a refund?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;criteria&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The customer directly requests money back&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The customer reports a charge but does not request a refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;

&lt;span class="n"&gt;department&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;department&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;severity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;refund_probability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nouls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requests_refund&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;noul&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;department&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.65&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;destination&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;human-review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;destination&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;incident-response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;destination&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;department&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;

&lt;span class="n"&gt;input_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;estimated_cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;input_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;INPUT_USD_PER_MTOK&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;destination=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;destination&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;department_confidence=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;department&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;severity=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refund_probability=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;refund_probability&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_tokens=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output_tokens=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_tokens&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;elapsed_seconds=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;elapsed&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;estimated_cost_usd=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;estimated_cost&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 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.&lt;/p&gt;

&lt;p&gt;Pinning &lt;code&gt;jev-1.13.0&lt;/code&gt; prevents alias drift. In production, log the criteria version, probability distributions, chosen route, and human outcome.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sample output and cost
&lt;/h3&gt;

&lt;p&gt;A real run of the example above against &lt;code&gt;jev-1.13.0&lt;/code&gt; printed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model=jev-1.13.0
destination=incident-response
department_confidence=0.95
severity=2.00
refund_probability=0.06
input_tokens=528
output_tokens=77
elapsed_seconds=0.64
estimated_cost_usd=0.00002218
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;usage&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;elapsed_seconds&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Jev use cases
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Route support requests
&lt;/h3&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Wrong item delivered, replacement needed before Friday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Order 48213 arrived with two left boots. I need the replacement before the conference on Friday or I want to cancel."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"account_tier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"enterprise"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"earlier_tickets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jev-1.13.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"questions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"queue"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"choice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Which queue should own this ticket first?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"returns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Wrong, damaged, or missing items, replacements, and shipping problems"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Charges, invoices, refunds, and subscriptions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"technical"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Product failures, errors, and defects"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"account"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Access, identity, and permissions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"other"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"None of the other queues fit"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"impact"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"How much does the problem block the customer?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Minor inconvenience with a workaround"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"The item or order cannot be used as delivered"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Time-critical situation or total loss of the purchase"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"wants_cancel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"noul"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Is the customer asking to cancel an order or close the account?"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Code can accept a confident route, send uncertain tickets to a person, and apply exact entitlement rules from the account database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Review agent actions
&lt;/h3&gt;

&lt;p&gt;An agent can propose a tool call while Jev supplies a bounded semantic risk signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"user_goal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Remove generated build artifacts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shell"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rm -rf dist"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jev-1.13.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"questions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"risk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"choice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Classify the operational risk of the proposed action"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"read_only"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Does not modify state"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"reversible_write"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Changes state but has a reliable rollback"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"irreversible_write"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deletes or overwrites state without a reliable rollback"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"unclear"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The available state is insufficient"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"matches_goal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"noul"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Does the proposed action directly match the user's stated goal?"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check RAG evidence before generation
&lt;/h3&gt;

&lt;p&gt;Jev can classify whether a retrieved passage supports a claim before a generative model writes the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"question"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Does the service retain customer prompts for training?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"claim"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Customer requests are not used to train Jev."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"passage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Jev is not trained on customer requests or responses."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jev-1.13.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"questions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"support"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"choice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"How well does the passage support the claim?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The passage directly supports the claim"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"contradicted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The passage conflicts with the claim"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"insufficient"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The passage does not settle the claim"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"contains_instruction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"noul"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Does the passage contain an instruction aimed at changing the evaluator's behavior?"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Triage moderation and operations queues
&lt;/h3&gt;

&lt;p&gt;Moderation reports, invoices, incidents, and agent traces all end up in queues. Jev can classify the queue, score severity, and flag missing evidence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"monitoring alert"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"checkout-api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"p99 latency rose from 180 ms to 2.4 s over the last 20 minutes while the error rate stayed near 0.2%"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deploy checkout-api v2.31.0 finished 25 minutes ago"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jev-1.13.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"questions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"owner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"choice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Which team should take the first response for this alert?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"service_team"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The reporting service's own code or configuration"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Shared infrastructure, networking, or managed dependencies"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"database"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Storage, replication, or query performance"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"unclear"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The available evidence does not point to one owner"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"How much customer impact does the summary describe?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"No confirmed customer impact, worth watching"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Degraded latency for some customers"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"Customers blocked or data at risk"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"missing_evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"noul"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Is the alert missing context a first responder would need before acting?"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Route work to the right model or human
&lt;/h3&gt;

&lt;p&gt;Use Jev in front of more expensive handlers. A Choice can select deterministic lookup, a small generative model, a reasoning model, or human review:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"request"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Summarize the attached vendor contract and flag the clauses that change our data-retention obligations"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"attachments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"vendor-contract-v7.pdf"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"requester"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"procurement"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jev-1.13.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"questions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"route"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"choice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Which handler should receive this request?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"criteria"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"deterministic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A lookup, calculation, or template can answer it exactly"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"small_model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Short generation or extraction a cheap model handles well"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"reasoning_model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Long-document analysis or multi-step reasoning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"human"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Judgment, negotiation, or accountability beyond the model stack"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"in_scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"noul"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"instructions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Does the request fall inside the documented scope of the available handlers?"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Jev vs LLMs, classifiers, and rules
&lt;/h2&gt;

&lt;p&gt;Jev is not automatically better than the alternatives. It occupies a specific slot:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Rules and code&lt;/th&gt;
&lt;th&gt;Traditional classifier&lt;/th&gt;
&lt;th&gt;LLM structured output&lt;/th&gt;
&lt;th&gt;TypeSafe Jev&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Best job&lt;/td&gt;
&lt;td&gt;exact deterministic checks&lt;/td&gt;
&lt;td&gt;stable prediction task with labeled data&lt;/td&gt;
&lt;td&gt;generation, extraction, explanation, broad reasoning&lt;/td&gt;
&lt;td&gt;bounded semantic decisions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output&lt;/td&gt;
&lt;td&gt;exact programmed value&lt;/td&gt;
&lt;td&gt;fixed trained labels and scores&lt;/td&gt;
&lt;td&gt;generated text constrained to a schema&lt;/td&gt;
&lt;td&gt;predefined Choice, Score, or Noul answers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New taxonomy&lt;/td&gt;
&lt;td&gt;edit code&lt;/td&gt;
&lt;td&gt;usually retrain&lt;/td&gt;
&lt;td&gt;change prompt or schema&lt;/td&gt;
&lt;td&gt;change request criteria&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probability signal&lt;/td&gt;
&lt;td&gt;not applicable&lt;/td&gt;
&lt;td&gt;often available, calibration varies&lt;/td&gt;
&lt;td&gt;usually not a reliable application probability&lt;/td&gt;
&lt;td&gt;first-class probabilities; local validation still required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Explanation&lt;/td&gt;
&lt;td&gt;code is inspectable&lt;/td&gt;
&lt;td&gt;usually limited&lt;/td&gt;
&lt;td&gt;can generate one&lt;/td&gt;
&lt;td&gt;no generated explanation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exact math and policy&lt;/td&gt;
&lt;td&gt;best option&lt;/td&gt;
&lt;td&gt;depends on trained features&lt;/td&gt;
&lt;td&gt;possible but unreliable&lt;/td&gt;
&lt;td&gt;documented poor fit; keep it in code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment tradeoff&lt;/td&gt;
&lt;td&gt;engineering maintenance&lt;/td&gt;
&lt;td&gt;labels, training, serving&lt;/td&gt;
&lt;td&gt;latency, cost, parsing, model variance&lt;/td&gt;
&lt;td&gt;hosted early access, bounded interface, no generation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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% (&lt;a href="https://github.com/bitnovus/jev-spam-eval" rel="noopener noreferrer"&gt;full writeup&lt;/a&gt;). The criteria had been refined after reviewing labeled examples, so this was useful early evidence, not magic zero-data victory.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The cost and latency story is still notable.&lt;/p&gt;

&lt;p&gt;TypeSafe reports 70 to 500 ms end-to-end latency and $0.042 per million input tokens for suitable requests.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to use TypeSafe AI Jev
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Keep arithmetic, counting, numeric interpolation, and date comparisons in code. TypeSafe's &lt;a href="https://docs.typesafe.ai/model-jaggedness/jev-1.13" rel="noopener noreferrer"&gt;limitations page&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Test non-English workloads separately. TypeSafe says English is Jev's strongest training language and other languages are not handled equally well.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to evaluate Jev on your workload
&lt;/h2&gt;

&lt;p&gt;Start in shadow mode:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick one reversible decision with a closed answer set.&lt;/li&gt;
&lt;li&gt;Freeze a representative labeled dataset and the exact criteria text.&lt;/li&gt;
&lt;li&gt;Run Jev and the current baseline on identical records.&lt;/li&gt;
&lt;li&gt;Measure per-class errors, false automatic actions, human-review rate, calibration, end-to-end latency, and cost per completed decision.&lt;/li&gt;
&lt;li&gt;Tune thresholds by consequence. Sending a ticket to the wrong queue and approving a destructive action do not deserve the same confidence floor.&lt;/li&gt;
&lt;li&gt;Pin the model version and rerun the suite before changing either model or criteria.&lt;/li&gt;
&lt;li&gt;Keep permissions, arithmetic, and state changes in deterministic code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you use TypeSafe AI Jev?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>jev</category>
      <category>agents</category>
    </item>
    <item>
      <title>Implementing Postgres Row-Level Security in Next.js: The Drizzle Multi-Tenant Pattern</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Wed, 16 Sep 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/implementing-postgres-row-level-security-in-nextjs-the-drizzle-multi-tenant-pattern-428l</link>
      <guid>https://dev.to/u11d/implementing-postgres-row-level-security-in-nextjs-the-drizzle-multi-tenant-pattern-428l</guid>
      <description>&lt;p&gt;The first time we turned on Row-Level Security, nothing happened. Policies were in place, &lt;code&gt;ENABLE ROW LEVEL SECURITY&lt;/code&gt; had run, and every query still returned every tenant's rows. Took a minute to remember: we were connected as &lt;code&gt;postgres&lt;/code&gt;, the superuser, and superusers ignore RLS entirely. That one gotcha basically shaped the rest of this setup.&lt;/p&gt;

&lt;p&gt;The problem we were actually trying to solve is boring and everyone's had it: a route handler forgets &lt;code&gt;WHERE organization_id = ?&lt;/code&gt;, code review doesn't catch it because the query still "works," and Tenant A quietly gets to see Tenant B's data. Nobody notices until someone does.&lt;/p&gt;

&lt;p&gt;Our answer was to stop trusting application code to remember the filter and push isolation into Postgres itself with &lt;a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html" rel="noopener noreferrer"&gt;Row-Level Security&lt;/a&gt;. The pitch is simple: if a query forgets tenant scoping, it should get zero rows back, not someone else's data. Here's the setup we landed on — two DB roles, a &lt;code&gt;SET LOCAL&lt;/code&gt; session variable, a Next.js wrapper so route handlers don't have to think about any of this — plus the four things that actually bit us building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two roles, two connections
&lt;/h2&gt;

&lt;p&gt;Back to that first surprise: &lt;strong&gt;superusers and table owners bypass RLS by default.&lt;/strong&gt; The &lt;a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html" rel="noopener noreferrer"&gt;docs&lt;/a&gt; say it plainly — superuser or &lt;code&gt;BYPASSRLS&lt;/code&gt;, policies don't apply, full stop. Table owners get the same pass unless you explicitly add &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;. If your app connects as &lt;code&gt;postgres&lt;/code&gt;, which is what basically every local Postgres setup defaults to, your carefully written policies are just decoration.&lt;/p&gt;

&lt;p&gt;So we run two roles now:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;RLS&lt;/th&gt;
&lt;th&gt;Used by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;postgres&lt;/code&gt; (superuser)&lt;/td&gt;
&lt;td&gt;Bypassed&lt;/td&gt;
&lt;td&gt;Auth library, admin routes, migrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;app_user&lt;/code&gt; (non-superuser)&lt;/td&gt;
&lt;td&gt;Enforced&lt;/td&gt;
&lt;td&gt;All tenant-scoped queries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each gets its own pool, wrapped as two Drizzle instances:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// db.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getDbAdmin&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;PostgresJsDatabase&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;_dbAdmin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;adminConnectionConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// DATABASE_URL, superuser&lt;/span&gt;
      &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app-admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;_dbAdmin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drizzle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;_dbAdmin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getDb&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;PostgresJsDatabase&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;appConnectionConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// APP_DATABASE_URL, app_user&lt;/span&gt;
      &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app-tenant&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;_db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drizzle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth flagging two things here. First, the lazy init isn't stylistic — Next.js runs module scope at build time, and if you open a connection pool at import time, &lt;code&gt;next build&lt;/code&gt; needs a live database to succeed. That bit us once in CI. Wrapping construction in a getter defers the actual connection until something queries it.&lt;/p&gt;

&lt;p&gt;We also wrap both in a &lt;code&gt;Proxy&lt;/code&gt; so call sites can still just import &lt;code&gt;db&lt;/code&gt; and use it like a normal object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Proxy&lt;/span&gt;&lt;span class="p"&gt;({}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;PostgresJsDatabase&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Schema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Reflect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getDb&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;prop&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I won't pretend this is elegant. It works, and it means nobody importing &lt;code&gt;db&lt;/code&gt; has to know it's lazy underneath. Fine trade.&lt;/p&gt;

&lt;p&gt;Second thing: we set a distinct &lt;code&gt;application_name&lt;/code&gt; per pool. Costs nothing, and the first time something's connection-starved you'll be very glad &lt;code&gt;pg_stat_activity&lt;/code&gt; tells you exactly which pool is choking instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy itself
&lt;/h2&gt;

&lt;p&gt;Every tenant table gets the same shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.organization_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.organization_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things matter more than they look like they should.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;true&lt;/code&gt; as the second argument to &lt;a href="https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADMIN-SET" rel="noopener noreferrer"&gt;&lt;code&gt;current_setting&lt;/code&gt;&lt;/a&gt; means "don't error if the setting's unset, just give me NULL." NULL compared to anything is NULL, and Postgres treats a NULL policy result as "exclude this row" — same as false. So a query that runs without tenant context isn't a crash and isn't a leak, it's just an empty table. Someone forgets to set context, they get a confusing empty result, they go debug it. Nobody's data went anywhere in the meantime. That's the property I actually care about here.&lt;/p&gt;

&lt;p&gt;And don't skip &lt;code&gt;WITH CHECK&lt;/code&gt;. It's easy to write the &lt;code&gt;USING&lt;/code&gt; clause, ship it, and feel done — but &lt;code&gt;USING&lt;/code&gt; only governs reads. Without &lt;code&gt;WITH CHECK&lt;/code&gt;, nothing stops a tenant from &lt;code&gt;INSERT&lt;/code&gt;ing or &lt;code&gt;UPDATE&lt;/code&gt;ing a row with someone else's &lt;code&gt;organization_id&lt;/code&gt; in it. We missed this on the first pass of one migration and caught it in review, which is exactly the kind of thing you don't want to catch in review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting context into the transaction: SET LOCAL
&lt;/h2&gt;

&lt;p&gt;Policies check &lt;code&gt;current_setting('app.organization_id')&lt;/code&gt;, so something has to set it per request. The obvious first attempt — plain &lt;code&gt;SET app.organization_id = '...'&lt;/code&gt; — is wrong, and it's wrong in a way that won't show up until you're under load. &lt;code&gt;SET&lt;/code&gt; is session-scoped, connections are pooled, and a session-scoped setting sticks around for whoever grabs that connection next. That's tenant bleed waiting to happen the moment your pool gets reused across requests, which it will.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.postgresql.org/docs/current/sql-set.html" rel="noopener noreferrer"&gt;&lt;code&gt;SET LOCAL&lt;/code&gt;&lt;/a&gt; is the fix — scoped to the current transaction, reverts automatically on commit or rollback. Open transaction, &lt;code&gt;SET LOCAL&lt;/code&gt;, run your queries, done, connection goes back to the pool clean.&lt;/p&gt;

&lt;p&gt;In Next.js we wrapped this into one function every tenant route goes through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuthenticatedHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;RouteContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// session lookup&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setOrgContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;castTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Route handlers never see any of this. They just get a &lt;code&gt;tx&lt;/code&gt; that's already scoped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// only this org's rows, guaranteed&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;WHERE organizationId = ?&lt;/code&gt; anywhere in that query, and it's still safe. We keep the explicit filters in most places anyway — belt and suspenders, and it documents intent for the next person reading the code — but the point stands: if you forgot it, RLS still has you.&lt;/p&gt;

&lt;p&gt;Server components got a matching helper since they're not going through route handlers at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withOrgDb&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DbInstance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getDb&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setOrgContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;castTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Gotcha #1: you can't parameterize SET
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;setOrgContext&lt;/code&gt; looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UUID_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{8}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{12}&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setOrgContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DrizzleTx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;UUID_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid organization ID format&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`SET LOCAL app.organization_id = '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;sql.raw()&lt;/code&gt; should bother you a little — it bothered me too, first time I wrote it. Here's the thing though: &lt;code&gt;SET&lt;/code&gt; is a utility command, not a regular statement, and utility commands in Postgres just don't take &lt;code&gt;$1&lt;/code&gt;-style parameters. You cannot write &lt;code&gt;SET LOCAL app.organization_id = $1&lt;/code&gt;. It'll error. You've got two real options — &lt;code&gt;set_config('app.organization_id', $1, true)&lt;/code&gt;, which is a normal function call and does accept a parameter, or raw string interpolation gated behind strict validation. We went with the second, gated on a UUID regex: the value either looks exactly like a UUID or the function throws before it ever touches SQL. I still don't love it stylistically, but there's no injection surface left once that regex has to match.&lt;/p&gt;

&lt;p&gt;Point either way: you're going to hit this wall the first time you try to parameterize a &lt;code&gt;SET&lt;/code&gt;, and it's not a bug in your code, it's just how the command works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #2: Drizzle's transaction type doesn't match its client type
&lt;/h2&gt;

&lt;p&gt;Drizzle's &lt;a href="https://orm.drizzle.team/docs/transactions" rel="noopener noreferrer"&gt;transaction callback&lt;/a&gt; hands you a &lt;code&gt;PostgresJsTransaction&lt;/code&gt;, and that type is not structurally the same as &lt;code&gt;PostgresJsDatabase&lt;/code&gt; — even though every method you'd actually call (&lt;code&gt;select&lt;/code&gt;, &lt;code&gt;insert&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;) is identical between them. If &lt;code&gt;AuthContext.tx&lt;/code&gt; gets typed as the transaction class, every shared data function downstream now needs that same internal type, which means importing a class Drizzle never really meant for you to reach into.&lt;/p&gt;

&lt;p&gt;We just centralized the cast once and moved on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Extract the tx type without importing internal Drizzle classes&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;DrizzleTx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Parameters&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Parameters&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DbInstance&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transaction&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;castTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DrizzleTx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;DbInstance&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;DbInstance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now shared data-layer code just takes a &lt;code&gt;DbInstance&lt;/code&gt; and doesn't care if it's the root client, the admin client, or a live transaction. One cast, one comment explaining why, easy to delete later if Drizzle ever unifies the types properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #3: don't let a stream hold your transaction
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;withAuth&lt;/code&gt; holds a transaction — and the pooled connection under it — for as long as the request runs. For a JSON endpoint that returns in 40ms, fine. For a Server-Sent Events endpoint that stays open for twenty minutes, that's a problem: with a pool of 4, three people leaving a dashboard tab open exhausts it and everyone else starts timing out.&lt;/p&gt;

&lt;p&gt;We learned this the hard way and split off a separate wrapper for streaming routes — it authenticates, but deliberately never opens a transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withAuthStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Short transaction for the initial state read&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withOrgDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReadableStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="cm"&gt;/* ...on each event, use withOrgDb() again for any query... */&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/event-stream&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule we now follow: RLS context only lives as long as a transaction, so keep the transaction as short as the query needs — never as long as the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #4: knowing which connection you're on
&lt;/h2&gt;

&lt;p&gt;Two Drizzle instances means the failure mode shifted from "forgot a WHERE clause" to "grabbed the wrong client." We ended up needing a cheat sheet:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Connection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tenant API route&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tx&lt;/code&gt; from &lt;code&gt;withAuth&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSE route&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;withOrgDb()&lt;/code&gt; per query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server component, tenant data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;withOrgDb()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server component, auth tables only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dbAdmin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin route&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dbAdmin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth library internals&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dbAdmin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrations / background workers&lt;/td&gt;
&lt;td&gt;superuser&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reassuring part: the mistake in the safe direction fails loudly. Query tenant tables through &lt;code&gt;db&lt;/code&gt; with no context set, and you get zero rows — a visibly broken feature in dev, not a security incident. Using &lt;code&gt;dbAdmin&lt;/code&gt; where you meant tenant scoping is the dangerous direction, and that's the one worth actually watching for in review. At least it's greppable.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>drizzle</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Fluctum: Open-Source Dynamic Pricing Solution for Stores Selling Spot-Priced Goods</title>
      <dc:creator>Michał Miler</dc:creator>
      <pubDate>Mon, 14 Sep 2026 06:35:04 +0000</pubDate>
      <link>https://dev.to/u11d/fluctum-open-source-dynamic-pricing-solution-for-stores-selling-spot-priced-goods-ani</link>
      <guid>https://dev.to/u11d/fluctum-open-source-dynamic-pricing-solution-for-stores-selling-spot-priced-goods-ani</guid>
      <description>&lt;p&gt;If you sell gold, silver, or anything else priced off a live market, you've probably already run into this problem: the price you show a customer and the actual market price start drifting apart the moment you set it. Update prices too rarely and you're either giving away margin or overcharging people - and both cost you customers eventually, just in different ways.&lt;/p&gt;

&lt;p&gt;Most store owners assume there are only two options: pay developers to build a custom pricing system, or keep manually updating prices and accept the risk. There's a third option that's easy to miss - using an existing, already-built open-source solution instead of starting from scratch.&lt;/p&gt;

&lt;p&gt;This post lays out what each path actually costs, in plain terms, so you can decide which one makes sense for your store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Problem Shows Up
&lt;/h2&gt;

&lt;p&gt;Precious metals dealers feel this first, because gold and silver prices move constantly and the margins are thin enough that a stale price matters. But the same problem shows up anywhere a product's fair price is tied to a live index rather than something you set once and leave alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Precious and industrial metals&lt;/strong&gt; - bullion, plus copper, platinum, and palladium for wholesale operations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B2B catalogs synced to an ERP&lt;/strong&gt; - pricing that has to reflect what your internal systems say right now, not what a database field said last week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Currency-sensitive goods&lt;/strong&gt; - high-value items where the price also has to move with exchange rates, not just the underlying market.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same logic extends further, too - anything priced off a live sale index rather than a fixed catalog value (think collectible trading cards or limited-run goods pegged to recent resale prices) runs into the same drift problem, even if the tooling for those categories is less mature today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Cost of Building This Yourself
&lt;/h2&gt;

&lt;p&gt;"Just show a live price" sounds simple, but a live-pricing checkout has to get several things right at once, and getting any one of them wrong costs real money:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pulling a live price feed reliably&lt;/strong&gt;, including handling the moments the feed is slow or unavailable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updating the price a customer sees in real time&lt;/strong&gt;, without expensive full-page reloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Freezing the price at the right moment during checkout&lt;/strong&gt; - if the price can still move after the customer clicks "buy," you're back to the original problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refusing to complete an order if the frozen price has gone stale&lt;/strong&gt; - otherwise a customer could complete a purchase minutes after the quoted price expired.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic, but each one is a place a custom build can quietly get wrong, and the mistakes are the expensive kind: a wrong price that either loses margin on every sale or drives away customers who feel overcharged. This is why a live-pricing checkout is realistically a multi-week engineering project, not a quick add-on - even though the end result looks simple to a shopper.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Ready-Made Solution Looks Like
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://fluctum.io/" rel="noopener noreferrer"&gt;Fluctum&lt;/a&gt; is a free, open-source plugin built on top of &lt;a href="https://medusajs.com/" rel="noopener noreferrer"&gt;Medusa&lt;/a&gt;, an open-source e-commerce platform. Because it's a Medusa plugin rather than a separate system, it drops into an existing Medusa store and keeps everything else Medusa already gives you - multi-region and multi-currency support, promotions, customer accounts, taxes, shipping, and payments - while adding live pricing on top. It's installed by a developer once, and after that it runs on its own.&lt;/p&gt;

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

&lt;p&gt;You can try the live demo yourself at &lt;a href="https://fluctum.medusajs.site" rel="noopener noreferrer"&gt;fluctum.medusajs.site&lt;/a&gt; - add a product to the cart, watch the price update, and go through checkout to see the price lock in place.&lt;/p&gt;

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

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

&lt;h2&gt;
  
  
  Which one should you choose?
&lt;/h2&gt;

&lt;p&gt;If you already have engineering resources and want a pricing system that's deeply custom to your business (unusual pricing rules, an existing in-house platform you can't move off of), building it yourself is a legitimate option - just budget for the ongoing maintenance, not only the initial build.&lt;/p&gt;

&lt;p&gt;For most stores, especially ones already running or considering Medusa, starting from a free, working solution and customizing it only where needed is the faster and lower-risk path. Because Fluctum is open-source and built on Medusa, you own the resulting codebase outright rather than licensing access to someone else's product - the same store can be extended to sync with an ERP system, connect to warehouse or inventory systems, switch or add payment providers like Stripe or PayPal, or bolt on whatever custom business logic your store needs later. You're never locked into someone else's roadmap if your store eventually needs something different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Build it yourself&lt;/th&gt;
&lt;th&gt;Use Fluctum (free, open-source)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time to launch&lt;/td&gt;
&lt;td&gt;Months of developer time, plus testing&lt;/td&gt;
&lt;td&gt;Installed and configured in hours by a developer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ongoing maintenance&lt;/td&gt;
&lt;td&gt;Your team owns it - feed outages, checkout bugs, every future fix&lt;/td&gt;
&lt;td&gt;Maintained upstream; you install updates when you want them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upfront cost&lt;/td&gt;
&lt;td&gt;Developer time (custom-built, one-off)&lt;/td&gt;
&lt;td&gt;Free - MIT-licensed, no license fee&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk of a checkout pricing mistake&lt;/td&gt;
&lt;td&gt;You design and test the "lock the price at checkout" logic yourself&lt;/td&gt;
&lt;td&gt;Already built to handle the common failure modes (expired prices, stale quotes at payment time)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customization&lt;/td&gt;
&lt;td&gt;Full control, by definition&lt;/td&gt;
&lt;td&gt;Open-source and built on Medusa - you own the code, so you can extend it to ERP, storage/inventory, payments, or custom logic as your store needs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Static prices and live markets don't mix, but that doesn't mean the fix has to be an expensive custom build. Fluctum gives Medusa stores selling precious metals - or any other spot-priced goods - a free, working starting point, with the option to extend it later if your business needs something more specific.&lt;/p&gt;

</description>
      <category>ecommerce</category>
      <category>medusajs</category>
      <category>preciousmetals</category>
    </item>
    <item>
      <title>How to get fixed IP addresses for an AWS ALB with Global Accelerator</title>
      <dc:creator>Paweł Swiridow</dc:creator>
      <pubDate>Wed, 09 Sep 2026 11:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/how-to-get-fixed-ip-addresses-for-an-aws-alb-with-global-accelerator-oh8</link>
      <guid>https://dev.to/u11d/how-to-get-fixed-ip-addresses-for-an-aws-alb-with-global-accelerator-oh8</guid>
      <description>&lt;p&gt;An AWS Application Load Balancer gives you a DNS name, not client-facing addresses you can promise to another company forever. The practical workaround is to publish the two anycast IPv4 addresses from AWS Global Accelerator and route that traffic to the ALB behind the scenes.&lt;/p&gt;

&lt;p&gt;Application Load Balancers are great until someone asks, "What IP addresses should we whitelist?"&lt;/p&gt;

&lt;p&gt;That is where the fun starts. The addresses behind the ALB name can change as AWS scales and maintains the service. Telling a customer to whitelist whatever resolves today is not architecture. It is a calendar reminder for a future outage.&lt;/p&gt;

&lt;p&gt;This keeps the firewall conversation simple without replacing your load balancer, target groups, certificates, listener rules, or the other ALB machinery you already use.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Quick answer&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Use this pattern when the other side only accepts IP-based allowlists, but your service still needs ALB features such as HTTPS listeners, host routing, path routing, target groups, and health checks.&lt;/p&gt;

&lt;p&gt;Implementation checklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a Global Accelerator accelerator.&lt;/li&gt;
&lt;li&gt;Add a TCP listener, usually on port 443.&lt;/li&gt;
&lt;li&gt;Create an endpoint group in the same Region as the ALB.&lt;/li&gt;
&lt;li&gt;Register the ALB as the endpoint.&lt;/li&gt;
&lt;li&gt;Send both accelerator IPs to the firewall owner.&lt;/li&gt;
&lt;li&gt;Add a Route 53 alias to the accelerator if clients can connect by name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Client network
  -&amp;gt; allowlist 2 Global Accelerator IPs
  -&amp;gt; AWS edge location
  -&amp;gt; Global Accelerator listener :443
  -&amp;gt; regional endpoint group
  -&amp;gt; Application Load Balancer
  -&amp;gt; targets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whitelisting, allowlisting, firewall exception, "please make networking stop yelling" - same practical problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why ALB alone does not work&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;An ALB is addressed by DNS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-api-1234567890.eu-west-1.elb.amazonaws.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That DNS name resolves to AWS-managed addresses. They are not yours, they are not fixed, and they are not a contract.&lt;/p&gt;

&lt;p&gt;You can put a friendly name in Route 53:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api.example.com -&amp;gt; my-api-1234567890.eu-west-1.elb.amazonaws.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That helps humans. It does not help a firewall that only accepts destination IPs.&lt;/p&gt;

&lt;p&gt;Do not solve this by resolving the ALB name and sending the current addresses to a customer. Those addresses can change. The customer will eventually block your production traffic and then everyone gets to pretend DNS was the surprising part.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How Global Accelerator gives an ALB fixed inbound IPs&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;AWS Global Accelerator creates a fixed entry point for your application.&lt;/p&gt;

&lt;p&gt;For an IPv4 accelerator, AWS gives you two static anycast IP addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;203.0.113.10
203.0.113.11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are the addresses your customer allowlists as outbound destinations. You can point your own DNS name at them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api.example.com -&amp;gt; Global Accelerator alias
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or, when the other side insists on raw IPs, hand them the two accelerator addresses.&lt;/p&gt;

&lt;p&gt;Global Accelerator can use an Application Load Balancer as a standard accelerator endpoint. It also monitors endpoint health and can route to another Region if you add one later.&lt;/p&gt;

&lt;p&gt;The important detail: the two Global Accelerator IPs are client-facing destination IPs. They are not the source IPs your ALB sees. Do not create an ALB security group rule that says "allow inbound from these two Global Accelerator IPs" and expect it to work. That is the common foot-gun, and it wears steel-toed boots.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Configure AWS Global Accelerator for an ALB with Terraform&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Assume you already have an ALB listening on HTTPS.&lt;/p&gt;

&lt;p&gt;The Global Accelerator pieces are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An accelerator&lt;/li&gt;
&lt;li&gt;A listener on TCP 443&lt;/li&gt;
&lt;li&gt;An endpoint group in the ALB Region&lt;/li&gt;
&lt;li&gt;The ALB as an endpoint
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="s2"&gt;"aws_globalaccelerator_accelerator"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api-prod"&lt;/span&gt;
  &lt;span class="nx"&gt;ip_address_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"IPV4"&lt;/span&gt;
  &lt;span class="nx"&gt;enabled&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="nx"&gt;attributes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;flow_logs_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="s2"&gt;"aws_globalaccelerator_listener"&lt;/span&gt; &lt;span class="s2"&gt;"https"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;accelerator_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_globalaccelerator_accelerator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"TCP"&lt;/span&gt;
  &lt;span class="nx"&gt;client_affinity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NONE"&lt;/span&gt;

  &lt;span class="nx"&gt;port_range&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="s2"&gt;"aws_globalaccelerator_endpoint_group"&lt;/span&gt; &lt;span class="s2"&gt;"eu_west_1"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;listener_arn&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_globalaccelerator_listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;endpoint_group_region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-west-1"&lt;/span&gt;
  &lt;span class="nx"&gt;traffic_dial_percentage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;

  &lt;span class="nx"&gt;endpoint_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;endpoint_id&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="nx"&gt;weight&lt;/span&gt;                         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;
    &lt;span class="nx"&gt;client_ip_preservation_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After&amp;nbsp;&lt;code&gt;terraform apply&lt;/code&gt;, output the addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="s2"&gt;"global_accelerator_ips"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_globalaccelerator_accelerator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip_sets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;ip_addresses&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;global_accelerator_ips&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s2"&gt;"203.0.113.10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;"203.0.113.11"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are the addresses to give to the other side for inbound access to your service from their point of view. They allow outbound traffic to those destination IPs. You keep your ALB behind the accelerator.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Add DNS without losing the fixed IPs&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Create an alias record for the accelerator DNS name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="s2"&gt;"aws_route53_record"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_route53_zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zone_id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api.example.com"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"A"&lt;/span&gt;

  &lt;span class="nx"&gt;alias&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_globalaccelerator_accelerator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dns_name&lt;/span&gt;
    &lt;span class="nx"&gt;zone_id&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_globalaccelerator_accelerator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hosted_zone_id&lt;/span&gt;
    &lt;span class="nx"&gt;evaluate_target_health&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Customers that can allowlist by FQDN can use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Customers that require IPs use the two accelerator addresses. Yes, both. Sending only one is how you turn high availability into a coin toss with extra paperwork.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Protect the ALB from direct access&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Global Accelerator solves the fixed destination IP problem. It does not automatically make an internet-facing ALB impossible to reach directly through its own DNS name.&lt;/p&gt;

&lt;p&gt;If the ALB is internet-facing, someone can still try:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://my-api-1234567890.eu-west-1.elb.amazonaws.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That bypasses your pretty&amp;nbsp;&lt;code&gt;api.example.com&lt;/code&gt;&amp;nbsp;name and goes straight to the ALB. Security groups and AWS WAF still apply, but the request did not enter through the Global Accelerator IPs.&lt;/p&gt;

&lt;p&gt;The cleaner pattern is to use an internal ALB as the accelerator endpoint. Global Accelerator supports internal Application Load Balancer endpoints and creates elastic network interfaces in your VPC to deliver the traffic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="s2"&gt;"aws_lb"&lt;/span&gt; &lt;span class="s2"&gt;"api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"api-prod"&lt;/span&gt;
  &lt;span class="nx"&gt;internal&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancer_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"application"&lt;/span&gt;
  &lt;span class="nx"&gt;subnets&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnets&lt;/span&gt;
  &lt;span class="nx"&gt;security_groups&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then allow the application port on the ALB security group. With client IP preservation enabled for an ALB endpoint, your application can still read the original client address from&amp;nbsp;&lt;code&gt;X-Forwarded-For&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="s2"&gt;"aws_security_group_rule"&lt;/span&gt; &lt;span class="s2"&gt;"alb_https_in"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ingress"&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;from_port&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;
  &lt;span class="nx"&gt;to_port&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_blocks&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rule may look too open, but the ALB is internal. The point is that the ALB does not have a public route of its own. Traffic arrives through Global Accelerator into the VPC path AWS creates for the endpoint.&lt;/p&gt;

&lt;p&gt;If you must keep the ALB internet-facing, add compensating controls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use AWS WAF on the ALB.&lt;/li&gt;
&lt;li&gt;Require valid host headers.&lt;/li&gt;
&lt;li&gt;Require authentication at the application layer.&lt;/li&gt;
&lt;li&gt;Restrict known customer source ranges in the ALB security group if the client list is small and stable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not rely on obscuring the ALB DNS name. Obscurity is not a control. It is a shrug with a certificate.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Keep the real client IP&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;For ALB endpoints, Global Accelerator and ALB preserve the original client IP in&amp;nbsp;&lt;code&gt;X-Forwarded-For&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Your application will receive a header like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;X-Forwarded-For: 198.51.100.25
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there are multiple proxies, the header can contain multiple addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;X-Forwarded-For: 198.51.100.25, 10.0.12.34
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make sure your app or ingress layer knows which proxy chain it trusts. Blindly trusting every&amp;nbsp;&lt;code&gt;X-Forwarded-For&lt;/code&gt;&amp;nbsp;value from the internet is how logs become fan fiction.&lt;/p&gt;

&lt;p&gt;For nginx behind the ALB, configure trusted proxy ranges for your VPC or ALB layer, not the whole internet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;set_real_ip_from&lt;/span&gt; &lt;span class="mf"&gt;10.0&lt;/span&gt;&lt;span class="s"&gt;.0.0/8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;real_ip_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;real_ip_recursive&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adjust the CIDR to your actual network.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Verify the fixed IP setup&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Get the accelerator IPs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws globalaccelerator list-accelerators &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Accelerators[?Name==`api-prod`].IpSets[].IpAddresses[]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test HTTPS against each IP while sending the expected host header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--resolve&lt;/span&gt; api.example.com:443:203.0.113.10 https://api.example.com/health
curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--resolve&lt;/span&gt; api.example.com:443:203.0.113.11 https://api.example.com/health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the accelerator health:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws globalaccelerator describe-endpoint-group &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--endpoint-group-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ENDPOINT_GROUP_ARN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'EndpointGroup.EndpointDescriptions[*].{EndpointId:EndpointId,HealthState:HealthState,Weight:Weight}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check your application logs for the client IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'X-Forwarded-For'&lt;/span&gt; /var/log/app/access.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or query ALB access logs in Athena:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;client_ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;request_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;target_status_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nb"&gt;time&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;alb_logs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;request_url&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%/health%'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;Troubleshooting common errors&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If traffic does not reach the ALB, check these first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Symptom&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Likely cause&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;What to check&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;curl --resolve&lt;/code&gt;&amp;nbsp;times out&lt;/td&gt;
&lt;td&gt;The accelerator endpoint is unhealthy or the ALB security group blocks traffic&lt;/td&gt;
&lt;td&gt;Check&amp;nbsp;&lt;code&gt;describe-endpoint-group&lt;/code&gt;, ALB listener rules, target group health, and security group ingress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS certificate mismatch&lt;/td&gt;
&lt;td&gt;The request reached the ALB with the wrong host name&lt;/td&gt;
&lt;td&gt;Use&amp;nbsp;&lt;code&gt;--resolve api.example.com:443:&amp;lt;accelerator-ip&amp;gt;&lt;/code&gt;&amp;nbsp;and make sure the ALB certificate covers&amp;nbsp;&lt;code&gt;api.example.com&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Customer allowlisted one IP but traffic still fails sometimes&lt;/td&gt;
&lt;td&gt;Only one Global Accelerator IP was added to the firewall&lt;/td&gt;
&lt;td&gt;Give them both accelerator IPs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ALB is still reachable directly&lt;/td&gt;
&lt;td&gt;The ALB is internet-facing&lt;/td&gt;
&lt;td&gt;Use an internal ALB endpoint or add WAF, host-header checks, authentication, and source restrictions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App logs show a proxy IP instead of the client IP&lt;/td&gt;
&lt;td&gt;The app does not trust the right proxy chain&lt;/td&gt;
&lt;td&gt;Configure trusted proxy ranges and read&amp;nbsp;&lt;code&gt;X-Forwarded-For&lt;/code&gt;&amp;nbsp;correctly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The most common mistake is treating the two Global Accelerator IPs as source IPs for the ALB security group. They are destination IPs for clients. Your ALB will not see requests as coming from those two addresses.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Multi-Region is the obvious next step&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;You do not need multiple Regions to justify Global Accelerator. Fixed ingress IPs are already enough.&lt;/p&gt;

&lt;p&gt;But once it is in place, adding another ALB in another Region is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="s2"&gt;"aws_globalaccelerator_endpoint_group"&lt;/span&gt; &lt;span class="s2"&gt;"us_east_1"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;listener_arn&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_globalaccelerator_listener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;endpoint_group_region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
  &lt;span class="nx"&gt;traffic_dial_percentage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

  &lt;span class="nx"&gt;endpoint_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;endpoint_id&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;api_us_east_1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="nx"&gt;weight&lt;/span&gt;                         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;
    &lt;span class="nx"&gt;client_ip_preservation_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start with&amp;nbsp;&lt;code&gt;traffic_dial_percentage = 0&lt;/code&gt;, validate the stack, then dial traffic up when you are ready:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;traffic_dial_percentage&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is useful for migrations, blue-green deployments, and disaster recovery plans that contain fewer hopes and prayers.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Things to remember&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Global Accelerator is a good fit when external parties need stable destination IPs for your AWS-hosted service.&lt;/p&gt;

&lt;p&gt;Use this pattern when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clients must allowlist fixed IPs.&lt;/li&gt;
&lt;li&gt;You want an ALB behind those fixed IPs.&lt;/li&gt;
&lt;li&gt;You may want regional failover later.&lt;/li&gt;
&lt;li&gt;You want to avoid managing proxy instances or static Elastic IP hacks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Be clear about what it does not do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It does not give the ALB fixed IPs.&lt;/li&gt;
&lt;li&gt;It does not make an internet-facing ALB private.&lt;/li&gt;
&lt;li&gt;It does not let you use the two accelerator IPs as ALB security group sources.&lt;/li&gt;
&lt;li&gt;It does not replace application authentication or WAF.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The short version: if you need fixed IP addresses for an AWS ALB, use AWS Global Accelerator. ALB gives you flexible HTTP load balancing. Global Accelerator gives you two stable anycast IPs in front of it. Put them together when the network team on the other side says, "We need IPs, not DNS," and you would prefer not to build your own little networking sadness machine.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>infrastructure</category>
      <category>networking</category>
    </item>
    <item>
      <title>Building a Multilingual Shop with Next.js</title>
      <dc:creator>Paweł Sobolewski</dc:creator>
      <pubDate>Tue, 01 Sep 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/building-a-multilingual-shop-with-nextjs-47ck</link>
      <guid>https://dev.to/u11d/building-a-multilingual-shop-with-nextjs-47ck</guid>
      <description>&lt;p&gt;Adding multiple languages to a web application often starts with a simple step: replacing hardcoded strings with translation keys and adding a language switcher.&lt;/p&gt;

&lt;p&gt;In an e-commerce application, however, internationalization quickly becomes a broader architectural challenge.&lt;/p&gt;

&lt;p&gt;Language affects far more than the text displayed on the page: &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It becomes part of the URL,&lt;/li&gt;
&lt;li&gt;it influences routing and redirects,&lt;/li&gt;
&lt;li&gt;it determines which translation resources are loaded,&lt;/li&gt;
&lt;li&gt;it affects server and client rendering,&lt;/li&gt;
&lt;li&gt;it changes how data such as prices and dates should be formatted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this article, we’ll look at how to approach these problems in a &lt;a href="https://nextjs.org/" rel="noopener noreferrer"&gt;Next.js&lt;/a&gt; application using the App Router. Rather than focusing on the basics of translating strings, we’ll focus on the technical decisions behind the implementation: how the language is detected, how it’s propagated through the application, how translations are loaded on the server and client, and how localized routes are handled.&lt;/p&gt;

&lt;p&gt;The implementation presented here is based on a small e-commerce shop, with a product page as its main piece of domain content. It supports English, Polish, and German, and it intentionally stays focused on the internationalization layer rather than on building a complete commerce platform.&lt;/p&gt;

&lt;p&gt;The goal is to use this relatively simple application to explore the architecture behind multilingual Next.js applications and the trade-offs involved in choosing and implementing an i18n solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Remarks
&lt;/h2&gt;

&lt;p&gt;Before diving into the implementation, it’s worth noting a few deliberate simplifications in this project.&lt;/p&gt;

&lt;p&gt;The repository is intentionally designed to demonstrate the technical aspects of internationalization in Next.js, not to provide a complete, production-ready e-commerce application. As a result, some parts of the implementation are intentionally simplified.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/u11d-com/blog_nextjs-i18n" rel="noopener noreferrer"&gt;Repository link&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Product data is part of the translation resources
&lt;/h3&gt;

&lt;p&gt;To keep the example self-contained, product data such as names and descriptions is stored alongside the translation resources.&lt;/p&gt;

&lt;p&gt;In a real e-commerce application, this data would typically come from a domain-specific API or commerce backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Product page
    ↓
Commerce API
    ↓
Product data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In that setup, the i18n layer would primarily handle UI translations, while localized product content would be part of the product data model.&lt;/p&gt;

&lt;h3&gt;
  
  
  All languages are stored in a single file
&lt;/h3&gt;

&lt;p&gt;The example keeps translations for all supported languages in one file to make the structure easier to explore.&lt;/p&gt;

&lt;p&gt;In a larger application, each language would typically have its own resource files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;languages/
├── en/
│   ├── common.json
│   ├── home.json
│   └── product.json
├── pl/
│   ├── common.json
│   ├── home.json
│   └── product.json
└── de/
    ├── common.json
    ├── home.json
    └── product.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes translation files easier to manage independently and fits better with external localization workflows.&lt;/p&gt;

&lt;h3&gt;
  
  
  No external translation management system
&lt;/h3&gt;

&lt;p&gt;The project uses local resources and an API route to demonstrate the resource-loading boundary.&lt;/p&gt;

&lt;p&gt;In a production application, you might instead use a CMS, a translation management platform, or another external service. The &lt;code&gt;resourceLoader&lt;/code&gt; abstraction is intentionally designed so the underlying source can be swapped out without changing the components that consume translations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing an i18n Solution for Next.js
&lt;/h2&gt;

&lt;p&gt;Before implementing internationalization, the first step is choosing the right tool.&lt;/p&gt;

&lt;p&gt;Next.js provides the &lt;a href="https://nextjs.org/docs/app/guides/internationalization" rel="noopener noreferrer"&gt;routing primitives&lt;/a&gt; needed to build a multilingual application, but it doesn’t prescribe a translation library. That leaves several options: building a small custom solution, using a Next.js-focused library such as &lt;a href="https://github.com/amannn/next-intl" rel="noopener noreferrer"&gt;&lt;code&gt;next-intl&lt;/code&gt;&lt;/a&gt;, or adopting the broader i18next ecosystem through &lt;a href="https://github.com/i18next/next-i18next" rel="noopener noreferrer"&gt;&lt;code&gt;next-i18next&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For this project, I chose &lt;code&gt;next-i18next&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That decision wasn’t based solely on the number of translated strings. The application needs an i18n layer that works across Server and Client Components, supports namespaces, and gives us control over how translation resources are loaded. These are important capabilities for a shop where translations may eventually move from local files to an external CMS or another backend service.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;next-i18next&lt;/code&gt; is built on top of &lt;a href="https://www.i18next.com/" rel="noopener noreferrer"&gt;&lt;code&gt;i18next&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://react.i18next.com/" rel="noopener noreferrer"&gt;&lt;code&gt;react-i18next&lt;/code&gt;&lt;/a&gt;, while adding the Next.js-specific integration. The version used in this project supports the App Router, including Server Components, Client Components, and proxy-based language detection*&lt;em&gt;.&lt;/em&gt;* It also provides separate APIs such as &lt;code&gt;getT()&lt;/code&gt; for Server Components and &lt;code&gt;useT()&lt;/code&gt; for Client Components.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not build it ourselves?
&lt;/h3&gt;

&lt;p&gt;A custom implementation might initially look attractive. With a small application, a simple dictionary and a React context can be enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;translations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;de&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem is that translation lookup is only one part of the equation.&lt;/p&gt;

&lt;p&gt;Once the application needs server-side translations, client-side hydration, namespaces, fallback languages, language detection, or external translation resources, the custom solution starts accumulating infrastructure that an established i18n library already provides.&lt;/p&gt;

&lt;p&gt;There’s another important advantage: a mature i18n library gives us much more than a simple &lt;code&gt;key → string&lt;/code&gt; lookup.&lt;/p&gt;

&lt;p&gt;For example, i18next provides built-in support for &lt;strong&gt;interpolation&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;welcome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;John&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also supports &lt;a href="https://www.i18next.com/translation-function/plurals" rel="noopener noreferrer"&gt;pluralization&lt;/a&gt; without requiring us to implement language-specific rules ourselves, and &lt;code&gt;react-i18next&lt;/code&gt; provides the &lt;a href="https://react.i18next.com/latest/trans-component" rel="noopener noreferrer"&gt;&lt;code&gt;&amp;lt;Trans&amp;gt;&lt;/code&gt;&lt;/a&gt; component for translations that contain React elements or more complex markup.&lt;/p&gt;

&lt;p&gt;These features may seem like minor conveniences at first, but they quickly become important as the number and complexity of translations grow. Pluralization is particularly difficult to implement correctly because different languages can have completely different grammatical rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Language-Based Routing
&lt;/h2&gt;

&lt;p&gt;Once we have chosen the i18n library, the next decision is where the current language should live.&lt;/p&gt;

&lt;p&gt;For this application, the &lt;strong&gt;language is part of the URL&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/en
/pl
/de
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same applies to nested routes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/en/product/1
/pl/product/1
/de/product/1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is implemented using a dynamic segment in the App Router:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
└── [lng]/
    ├── page.tsx
    └── product/
        └── [id]/
            └── page.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;[lng]&lt;/code&gt; segment becomes a route parameter, so a product page receives both the language and the product identifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A small App Router detail:&lt;/strong&gt; Starting with Next.js 16.3.0, you can also access route parameters via &lt;code&gt;next/root-params&lt;/code&gt;. For example, instead of passing &lt;code&gt;lng&lt;/code&gt; through multiple component levels, a component can use:&lt;/p&gt;


&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/root-params&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;This can reduce prop drilling when deeper components need the language. It’s especially useful for components that rely on route parameters but don’t otherwise need props from their parent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Why put the language in the URL?
&lt;/h3&gt;

&lt;p&gt;The first benefit is that each language has its own URL.&lt;/p&gt;

&lt;p&gt;A user can navigate directly to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/pl/product/1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the server immediately knows which language to use to render the page.&lt;/p&gt;

&lt;p&gt;The URL is also shareable and bookmarkable. If someone sends a Polish product page to another user, they’re sharing the Polish version explicitly rather than relying on the recipient’s browser preferences.&lt;/p&gt;

&lt;p&gt;There is also a clear relationship between URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/en/product/1
/pl/product/1
/de/product/1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They represent the same resource in different languages.&lt;/p&gt;

&lt;p&gt;This is especially useful for SEO, where language-specific URLs can be indexed and connected using mechanisms such as &lt;code&gt;hreflang&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting the User's Language with Proxy
&lt;/h2&gt;

&lt;p&gt;Putting the language in the URL solves the routing problem, but it raises another question: what should happen when a user visits the application without specifying a language?&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no language in the URL, so the application needs a way to determine the most appropriate one.&lt;/p&gt;

&lt;p&gt;In this project, that logic lives in the Next.js Proxy and follows a simple priority order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;URL
 ↓
Cookie
 ↓
Accept-Language
 ↓
Fallback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key point is that these sources are not treated equally.&lt;/p&gt;

&lt;h3&gt;
  
  
  URL comes first
&lt;/h3&gt;

&lt;p&gt;If the requested URL already contains a supported language, we treat it as an explicit user choice.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/pl/product/1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;should always be rendered in Polish, even if the user's browser prefers German.&lt;/p&gt;

&lt;p&gt;The Proxy first extracts the first segment of the pathname and checks whether it is one of the supported languages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;urlLanguage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;supportedLanguages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urlLanguage&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a valid language is found, the request can continue normally.&lt;/p&gt;

&lt;p&gt;The Proxy also updates the language cookie to remember the user's explicit choice for subsequent visits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cookie as persistent preference
&lt;/h3&gt;

&lt;p&gt;If there is no language in the URL, the next source is the language cookie.&lt;/p&gt;

&lt;p&gt;The cookie allows us to remember a language selected during a previous visit without requiring the user to select it again.&lt;/p&gt;

&lt;p&gt;However, the cookie is deliberately lower in the priority chain than the URL.&lt;/p&gt;

&lt;p&gt;This means that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;URL&lt;/span&gt;:    /&lt;span class="n"&gt;de&lt;/span&gt;
&lt;span class="n"&gt;Cookie&lt;/span&gt;: &lt;span class="n"&gt;pl&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;results in German.&lt;/p&gt;

&lt;p&gt;The URL represents the current request, while the cookie represents a previously stored preference.&lt;/p&gt;

&lt;p&gt;Keeping this distinction prevents a stale cookie from overriding an explicit URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Falling back to &lt;code&gt;Accept-Language&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;If neither the URL nor the cookie provides a supported language, we can use the browser's &lt;code&gt;Accept-Language&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;A browser might send something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pl-PL,pl;q=0.9,en-US;q=0.8,en;q=0.7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application extracts the language codes and looks for the first one it supports.&lt;/p&gt;

&lt;p&gt;For this project, the supported languages are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;en
pl
de
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;so the example header would result in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is important to treat &lt;code&gt;Accept-Language&lt;/code&gt; as a &lt;strong&gt;preference&lt;/strong&gt;, rather than as a guaranteed language selection. The browser may request a language that the application does not support.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finally, use a fallback
&lt;/h3&gt;

&lt;p&gt;There is always a case where none of the detected languages are supported.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;URL&lt;/span&gt;:    /
&lt;span class="n"&gt;Cookie&lt;/span&gt;: &lt;span class="n"&gt;fr&lt;/span&gt;
&lt;span class="n"&gt;Header&lt;/span&gt;: &lt;span class="n"&gt;ja&lt;/span&gt;-&lt;span class="n"&gt;JP&lt;/span&gt;,&lt;span class="n"&gt;ja&lt;/span&gt;;&lt;span class="n"&gt;q&lt;/span&gt;=&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;9&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the application only supports English, Polish and German, there is no match.&lt;/p&gt;

&lt;p&gt;This is where the configured fallback language is used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;en
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Translation Resources and Namespaces
&lt;/h2&gt;

&lt;p&gt;Once the language is established, the next question is how the application retrieves the translations.&lt;/p&gt;

&lt;p&gt;The project uses three namespaces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;common
home
product
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rather than keeping every translation in a single large dictionary, namespaces let us group resources by responsibility.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;common
├── navigation
├── language switcher
└── shared UI

home
├── hero
└── product grid

product
├── product details
├── add to cart
└── stock information
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This becomes increasingly useful as the application grows. A single translation file works well at first, but over time it becomes harder to maintain and to understand which part of the application owns a particular key.&lt;/p&gt;

&lt;p&gt;Namespaces also provide a useful loading boundary. A product page doesn’t necessarily need every translation used by the homepage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loading resources through &lt;code&gt;resourceLoader&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The interesting part of this implementation is how those resources are loaded.&lt;/p&gt;

&lt;p&gt;Instead of importing the translation JSON directly into the application, &lt;code&gt;next-i18next&lt;/code&gt; is configured with a custom &lt;code&gt;resourceLoader&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;resourceLoader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_BASE_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/fetchTranslations?lang=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;ns=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`Failed to load translations: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;namespace&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why introduce an API layer?
&lt;/h3&gt;

&lt;p&gt;For local JSON files, calling an API might seem unnecessary—we could simply import them.&lt;/p&gt;

&lt;p&gt;The API is included here to illustrate a more realistic setup, where translations are managed outside the application bundle.&lt;/p&gt;

&lt;p&gt;Imagine replacing the current implementation with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resourceLoader
     ↓
    CMS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resourceLoader
     ↓
translation service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of the application can remain unchanged.&lt;/p&gt;

&lt;p&gt;This separation is especially useful for e-commerce applications, where translations are often managed by content or localization teams rather than developers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server and Client Components
&lt;/h2&gt;

&lt;p&gt;With the language and translation resources in place, we still need to answer one key question: how do Server Components and Client Components access translations?&lt;/p&gt;

&lt;p&gt;The App Router provides two execution environments, and &lt;code&gt;next-i18next&lt;/code&gt; offers an API for each.&lt;/p&gt;

&lt;p&gt;On the server, translations can be accessed with &lt;code&gt;getT()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;product&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the client, translations are accessed through &lt;code&gt;useT()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;common&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;addToCart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This distinction matters because using translations doesn’t automatically mean a component needs to become a Client Component.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep translated content on the server
&lt;/h3&gt;

&lt;p&gt;A product page is a good example.&lt;/p&gt;

&lt;p&gt;There’s usually no reason to move the entire page to the client just because it contains translated text. The product name, description, labels, and other static content can be translated as the page is rendered on the server.&lt;/p&gt;

&lt;p&gt;That way, the application can send translated HTML in the initial response instead of waiting for client-side JavaScript to initialize the translation layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Providing the resources from the layout
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;[lng]/layout.tsx&lt;/code&gt; file is where the server-loaded translation resources are passed into the client-side i18n context.&lt;/p&gt;

&lt;p&gt;The layout initializes the server-side i18n instance and obtains the loaded resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;i18n&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getT&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getResources&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those resources are then passed to the &lt;strong&gt;provider&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;I18nProvider&lt;/span&gt;
  &lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;resources&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/I18nProvider&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The layout therefore establishes the translation context once for the entire language-specific part of the application.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this separation matters
&lt;/h3&gt;

&lt;p&gt;With the App Router, we should generally default to Server Components.&lt;/p&gt;

&lt;p&gt;The reason is simple: turning a component into a Client Component moves its logic into the browser and can add JavaScript that isn’t otherwise necessary.&lt;/p&gt;

&lt;p&gt;Internationalization shouldn’t be the reason to do that.&lt;/p&gt;

&lt;p&gt;If a component only needs to render translated content, it can remain a Server Component and use &lt;code&gt;getT()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;useT()&lt;/code&gt; should be reserved for components that already need to run on the client — for example, because they handle user interaction, browser events, local state, or other client-side behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Formatting Prices
&lt;/h2&gt;

&lt;p&gt;Internationalization is not limited to translating text. Numbers, dates, currencies, and other values often need to be formatted according to the user's language as well.&lt;/p&gt;

&lt;p&gt;Prices are a simple example.&lt;/p&gt;

&lt;p&gt;The same value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1999.99
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;can be displayed differently depending on the language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$1,999
1 999 zł
1.999 €
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this project, the language used in the URL is a short code such as &lt;code&gt;en&lt;/code&gt;, &lt;code&gt;pl&lt;/code&gt;, or &lt;code&gt;de&lt;/code&gt;. However, the &lt;code&gt;Intl&lt;/code&gt; APIs work with more specific locale identifiers, such as &lt;code&gt;en-US&lt;/code&gt; or &lt;code&gt;pl-PL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;formatPrice&lt;/code&gt; utility handles this mapping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LOCALE_CURRENCY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PLN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;de&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EUR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LOCALE_BCP47&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pl-PL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;de&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;de-DE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two separate mappings here.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LOCALE_CURRENCY&lt;/code&gt; determines which currency should be displayed, while &lt;code&gt;LOCALE_BCP47&lt;/code&gt; determines the regional formatting conventions used by &lt;code&gt;Intl.NumberFormat&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The actual formatting is then kept in a small utility:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LOCALE_CURRENCY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bcp47&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LOCALE_BCP47&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bcp47&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maximumFractionDigits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives components a simple API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;formatPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;without requiring them to know anything about currencies, BCP 47 locale identifiers, or &lt;code&gt;Intl.NumberFormat&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Localized Navigation
&lt;/h2&gt;

&lt;p&gt;As language becomes part of the URL, navigation is one of the first areas where i18n can start leaking into your application code.&lt;/p&gt;

&lt;p&gt;Without an abstraction, every link needs to be aware of the current language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Link&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/product/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;Product&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Link&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works, but it couples the component directly to the routing structure.&lt;/p&gt;

&lt;p&gt;It also gets repetitive: every developer has to remember that the first URL segment represents the current language.&lt;/p&gt;

&lt;p&gt;To avoid this, the project introduces a &lt;code&gt;LocalizedLink&lt;/code&gt; component.&lt;/p&gt;

&lt;h3&gt;
  
  
  LocalizedLink
&lt;/h3&gt;

&lt;p&gt;The idea is simple: components provide a path relative to the language root, and &lt;code&gt;LocalizedLink&lt;/code&gt; adds the current language automatically.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Link&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/product/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;Product&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Link&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;we can write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;LocalizedLink&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/product/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;Product&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/LocalizedLink&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the current language is Polish, the resulting URL is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/pl/product/1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For German, it becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/de/product/1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way, the component hides a routing detail that’s irrelevant to the component using it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Switching between languages
&lt;/h3&gt;

&lt;p&gt;The language switcher is a slightly different case.&lt;/p&gt;

&lt;p&gt;Here, we want to change the language while preserving the current route.&lt;/p&gt;

&lt;p&gt;For example, if the user is currently on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/pl/product/1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;switching to German should result in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/de/product/1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;rather than simply navigating to &lt;code&gt;/de&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The switcher can therefore treat the current pathname as a resource-independent path and replace only the language segment.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Current URL
/pl/product/1
      │
      │ replace language
      ▼
/de/product/1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is important for product pages because changing the language should not interrupt the user's current context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production Considerations
&lt;/h2&gt;

&lt;p&gt;The implementation covers the core architecture, but a production application would still need to address a few additional concerns.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caching translation resources
&lt;/h3&gt;

&lt;p&gt;Translation resources are usually much more stable than application data. Fetching the same translations on every request would therefore be wasteful.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;resourceLoader&lt;/code&gt; is a natural place to introduce caching and revalidation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Component
    ↓
next-i18next
    ↓
resourceLoader
    ↓
cache
    ↓
translation source
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact strategy depends on where the translations are stored. With a CMS or external translation service, the application could use Next.js caching and revalidation to avoid unnecessary requests while still allowing updated translations to reach users without rebuilding the entire application.&lt;/p&gt;

&lt;h3&gt;
  
  
  SEO and &lt;code&gt;hreflang&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Language-specific URLs also make SEO an important consideration.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/en/product/1
/pl/product/1
/de/product/1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These URLs represent different language versions of the same page. A production application should expose this relationship to search engines using appropriate &lt;code&gt;hreflang&lt;/code&gt; metadata.&lt;/p&gt;

&lt;p&gt;Canonical URLs should also be generated consistently so that each language version points to itself as the canonical URL, while the &lt;code&gt;hreflang&lt;/code&gt; links connect the available alternatives.&lt;/p&gt;

&lt;p&gt;The routing architecture already provides a clear URL for each language version. SEO metadata can build on top of that structure instead of inferring the language from cookies or client-side state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Building a multilingual application with Next.js isn’t just about translating strings. Language affects routing, request handling, resource loading, rendering, and navigation, so internationalization quickly becomes an architectural concern.&lt;/p&gt;

&lt;p&gt;The implementation presented here addresses those concerns at well-defined boundaries. The language is part of the URL, Proxy handles language detection, &lt;code&gt;next-i18next&lt;/code&gt; provides the translation layer, and Server and Client Components use different APIs depending on where translations are needed. Application-specific abstractions such as &lt;code&gt;LocalizedLink&lt;/code&gt; then keep language-related details out of regular components.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>nextjs</category>
      <category>programming</category>
    </item>
    <item>
      <title>Real-Time Dynamic Pricing in Medusa — A Developer's Guide to Fluctum</title>
      <dc:creator>Michał Miler</dc:creator>
      <pubDate>Wed, 26 Aug 2026 07:37:20 +0000</pubDate>
      <link>https://dev.to/u11d/real-time-dynamic-pricing-in-medusa-a-developers-guide-to-fluctum-af6</link>
      <guid>https://dev.to/u11d/real-time-dynamic-pricing-in-medusa-a-developers-guide-to-fluctum-af6</guid>
      <description>&lt;p&gt;Selling gold, silver, or other spot-priced goods online creates a pricing challenge most e-commerce platforms don't handle: market quotes move continuously, and a product's listed price can change between add-to-cart and checkout. That mismatch forces merchants to either update prices constantly (impractical) or accept losses and unhappy customers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://medusajs.com" rel="noopener noreferrer"&gt;Medusa&lt;/a&gt;'s module system makes it straightforward to add custom pricing logic without forking the platform. You even don't have to design and maintain a custom pricing engine yourself — a community-built Medusa plugin handles the heavy lifting. &lt;a href="https://fluctum.io" rel="noopener noreferrer"&gt;Fluctum&lt;/a&gt; is an open-source Medusa-based solution that implements the full stack you need: scheduled spot-price ingestion, real-time storefront updates via Server-Sent Events (SSE), and auditable price locks at checkout.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Fluctum
&lt;/h2&gt;

&lt;p&gt;Fluctum fetches live spot prices on a schedule, streams them to the storefront, and locks the computed price for a cart when checkout begins. It ships as two pieces: the installable plugin (&lt;code&gt;@u11d/medusa-dynamic-pricing&lt;/code&gt;) for adding dynamic pricing to an existing Medusa store, and &lt;code&gt;fluctum_starter&lt;/code&gt;, a ready-to-use template repository (Medusa backend + Next.js storefront + Docker Compose) for running the whole flow locally.&lt;/p&gt;

&lt;p&gt;This guide uses the starter because it's the fastest way to see live pricing, SSE updates, and checkout locking working end to end. For full configuration and option details, see the &lt;a href="https://www.npmjs.com/package/@u11d/medusa-dynamic-pricing" rel="noopener noreferrer"&gt;plugin npm page&lt;/a&gt;. Fluctum is MIT-licensed and the starter ships with four metals (XAU, XAG, XPT, XPD) out of the box, and it can be extended to price any volatile asset.&lt;/p&gt;

&lt;p&gt;In this article, we'll walk through setting up the starter locally, verifying the live pricing and checkout-lock flow end to end, how Fluctum computes the final price, and deploying it to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node.js v24+&lt;/li&gt;
&lt;li&gt;pnpm v11+ (&lt;code&gt;corepack enable&lt;/code&gt; picks up the pinned version automatically)&lt;/li&gt;
&lt;li&gt;Docker &amp;amp; Docker Compose (for local PostgreSQL and Redis)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Create your repo from the template
&lt;/h3&gt;

&lt;p&gt;Click &lt;a href="https://github.com/u11d-com/fluctum_starter/generate" rel="noopener noreferrer"&gt;Use this template&lt;/a&gt; on GitHub to create your own repo pre-wired with the plugin, or clone it directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/u11d-com/fluctum_starter.git my-store
&lt;span class="nb"&gt;cd &lt;/span&gt;my-store
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Install dependencies
&lt;/h3&gt;

&lt;p&gt;Run the project's package manager to install dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Configure environment variables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;backend/.env.template backend/.env
&lt;span class="nb"&gt;cp &lt;/span&gt;storefront/.env.template storefront/.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;backend/.env&lt;/code&gt; already points &lt;code&gt;DATABASE_URL&lt;/code&gt; and &lt;code&gt;REDIS_URL&lt;/code&gt; at the local Docker services, and &lt;code&gt;JWT_SECRET&lt;/code&gt;/&lt;code&gt;COOKIE_SECRET&lt;/code&gt; are pre-filled, so no changes are needed there.&lt;/p&gt;

&lt;p&gt;Add &lt;code&gt;GOLD_API_KEY&lt;/code&gt; if you have a &lt;a href="https://www.goldapi.io/" rel="noopener noreferrer"&gt;goldapi.io&lt;/a&gt; key to pull real spot prices. Otherwise, leave it unset and the plugin falls back to &lt;code&gt;randomProvider&lt;/code&gt;, which generates realistic-looking fluctuating prices - useful for local development and for testing the checkout-lock flow without a live feed dependency. You'll set &lt;code&gt;NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY&lt;/code&gt; in &lt;code&gt;storefront/.env&lt;/code&gt; in step 6, after the admin user exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Start infrastructure
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This starts PostgreSQL on port 5432 (database &lt;code&gt;fluctum&lt;/code&gt;) and Redis on port 6379.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Run migrations
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm run backend:migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs Medusa's database migrations and seeds initial data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Create an admin user and start the backend
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm run backend:create-admin
pnpm run backend:dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:9000/app&lt;/code&gt; → Settings → API Keys, copy the Publishable API key, and set it as &lt;code&gt;NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY&lt;/code&gt; in &lt;code&gt;storefront/.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  Step 7: Start the storefront
&lt;/h3&gt;

&lt;p&gt;In a separate terminal, with the backend still running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm run storefront:dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Backend: &lt;code&gt;http://localhost:9000&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Admin panel: &lt;code&gt;http://localhost:9000/app&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Storefront: &lt;code&gt;http://localhost:8000&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once both are confirmed working, stop them and use &lt;code&gt;pnpm run dev&lt;/code&gt; going forward to start backend and storefront together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing &amp;amp; Verification
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Visit the storefront and add a gold or silver product to the cart.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Stay on the cart page for a few seconds and watch the price update on its own - that's the live SSE feed, not a page refresh.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Click "Go to checkout." This calls &lt;code&gt;lockCartPrices(cartId, force=true)&lt;/code&gt;, which discards any existing locks for the cart and unconditionally recreates them - the price itself is computed the exact same way regardless of &lt;code&gt;force&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Submit the address form and continue through delivery and payment. The price stays fixed across these steps - it won't drift even if the underlying spot price moves in the meantime.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Click "Place order." &lt;code&gt;completeCartWorkflow.hooks.validate&lt;/code&gt; checks that every dynamically-priced line item has a valid, unexpired lock before letting the order complete.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

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

&lt;h2&gt;
  
  
  Deployment
&lt;/h2&gt;

&lt;p&gt;Once you've verified the flow locally, the starter is ready to deploy - either to &lt;a href="https://cloud.medusajs.com/signup" rel="noopener noreferrer"&gt;Medusa Cloud&lt;/a&gt;, the simplest option, or to your own infrastructure. If you'd rather self-host, see &lt;a href="https://deploymedusa.com/" rel="noopener noreferrer"&gt;deploymedusa.com&lt;/a&gt; for a managed self-hosting option.&lt;/p&gt;

&lt;h2&gt;
  
  
  Price Logic
&lt;/h2&gt;

&lt;p&gt;Fluctum never stores a dynamic price on the Medusa variant itself - it computes the final price on the fly from two of its own data models, plus the variant's weight:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SpotPrice&lt;/code&gt;&lt;/strong&gt; - a snapshot per material (&lt;code&gt;XAU&lt;/code&gt;, &lt;code&gt;XAG&lt;/code&gt;, ...), refreshed on every scheduled fetch. It stores &lt;code&gt;ask&lt;/code&gt;, &lt;code&gt;bid&lt;/code&gt;, and &lt;code&gt;price&lt;/code&gt;. The pricing formula uses &lt;code&gt;price&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;PricingRule&lt;/code&gt;&lt;/strong&gt; - the merchant-configured margin for a product or variant: &lt;code&gt;spread_factor&lt;/code&gt; (a multiplicative margin, e.g. &lt;code&gt;1.02&lt;/code&gt; for a 2% spread), &lt;code&gt;spread_fixed&lt;/code&gt; (a flat fee applied after the spread), &lt;code&gt;premium_percentage&lt;/code&gt;, and &lt;code&gt;premium_fixed&lt;/code&gt; (a second flat fee, e.g. a minting or handling charge).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;weight_oz&lt;/code&gt;&lt;/strong&gt; - the variant's weight in troy ounces, linked in via a module link rather than stored on either model above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The formula, rounded to two decimal places:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;base          = weight_oz × spot_price × spread_factor × currency_conversion
after_premium = base × (1 + premium_percentage / 100)
final_price   = after_premium + spread_fixed + premium_fixed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example, a 1 oz gold coin with a spot price of $2,000 and a 2% spread (&lt;code&gt;spread_factor: 1.02&lt;/code&gt;, no premiums or fixed fees) prices at &lt;code&gt;1.0 × 2000.00 × 1.02 = 2040.00&lt;/code&gt;. Add a 1.5% premium, a $5 fixed spread, and a $10 fixed premium, and the same coin prices at &lt;code&gt;2040 × 1.015 + 5.00 + 10.00 = 2085.60&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This calculation lives in a single function, &lt;code&gt;computeFinalPrice()&lt;/code&gt; in &lt;code&gt;dynamic-pricing-plugin/src/utils/price-formula.ts&lt;/code&gt;, exported from both the plugin's main entry point and a &lt;code&gt;./client&lt;/code&gt; subpath - so the storefront can import the exact same logic the backend uses without pulling in server-only Medusa dependencies. Every &lt;code&gt;CartPriceLock&lt;/code&gt; row created at checkout stores the resulting &lt;code&gt;unit_price&lt;/code&gt; alongside the &lt;code&gt;spot_price&lt;/code&gt; and all spread/premium factors used to compute it, so a locked price is always auditable after the fact. See &lt;a href="https://github.com/u11d-com/fluctum_medusa-dynamic-pricing-plugin/blob/main/docs/pricing-formula.md" rel="noopener noreferrer"&gt;docs/pricing-formula.md&lt;/a&gt; for the full breakdown and more worked examples.&lt;/p&gt;

&lt;h2&gt;
  
  
  Price-Lock Lifecycle
&lt;/h2&gt;

&lt;p&gt;Prices in the cart are live - they update continuously via SSE and are never locked while a customer is just browsing. Locking only happens at two points, both handled by the same store API route. &lt;code&gt;force&lt;/code&gt; doesn't change where the price comes from or how it's computed - it's purely a switch between reusing and recreating &lt;code&gt;CartPriceLock&lt;/code&gt; rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /store/dynamic-pricing/carts/:id/price-lock
    ?force=true   - unconditionally discards existing locks and creates fresh ones
    ?force=false  - reuses existing locks if they still match the cart's current line items (same variant IDs); otherwise creates new ones
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Lock behavior&lt;/th&gt;
&lt;th&gt;&lt;code&gt;force&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Click "Go to checkout" on the cart page&lt;/td&gt;
&lt;td&gt;Always creates fresh locks&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Click "Refresh prices" on checkout&lt;/td&gt;
&lt;td&gt;Always creates fresh locks&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkout page mounts (&lt;code&gt;CheckoutSummary&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Reuses existing valid locks, or creates them if none exist&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Submit address form&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CheckoutSummary&lt;/code&gt; remounts, existing valid locks reused&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Select delivery/payment (client-side &lt;code&gt;router.push()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No effect - &lt;code&gt;CheckoutSummary&lt;/code&gt; stays mounted&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Click "Place order"&lt;/td&gt;
&lt;td&gt;Does not create locks - reuses mount-time locks&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At order completion, &lt;code&gt;completeCartWorkflow.hooks.validate&lt;/code&gt; queries the &lt;code&gt;cart_price_lock&lt;/code&gt; table directly and rejects the order if any dynamically-priced variant has a missing or expired lock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Static prices and volatile-priced goods don't mix - one side always ends up paying for the gap between the listed price and the real market. Fluctum closes that gap without requiring a custom pricing engine: live SSE updates keep the storefront honest, and price locks at checkout entry and completion keep both the customer and the merchant protected from a price moving mid-transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Landing page: &lt;a href="https://fluctum.io/" rel="noopener noreferrer"&gt;fluctum.io&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Live demo: &lt;a href="https://fluctum.medusajs.site/us" rel="noopener noreferrer"&gt;fluctum.medusajs.site/us&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm package: &lt;a href="https://www.npmjs.com/package/@u11d/medusa-dynamic-pricing" rel="noopener noreferrer"&gt;@u11d/medusa-dynamic-pricing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Starter template: &lt;a href="https://github.com/u11d-com/fluctum_starter" rel="noopener noreferrer"&gt;u11d-com/fluctum_starter&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Plugin source: &lt;a href="https://github.com/u11d-com/fluctum_medusa-dynamic-pricing-plugin" rel="noopener noreferrer"&gt;u11d-com/fluctum_medusa-dynamic-pricing-plugin&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>medusa</category>
      <category>opensource</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Stop One EKS Node Group From Blocking Scale-Down in Another</title>
      <dc:creator>Daniel Kraszewski</dc:creator>
      <pubDate>Wed, 19 Aug 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/stop-one-eks-node-group-from-blocking-scale-down-in-another-2hb</link>
      <guid>https://dev.to/u11d/stop-one-eks-node-group-from-blocking-scale-down-in-another-2hb</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx11rf2lbou73d6tkwm27.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx11rf2lbou73d6tkwm27.png" alt="Stop One EKS Node Group From Blocking Scale-Down in Another" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If idle EKS nodes keep running while other node groups scale up around them, Cluster Autoscaler's global scale-down cooldown is the likely cause, and&amp;nbsp;&lt;code&gt;--scale-down-delay-type-local=true&lt;/code&gt;&amp;nbsp;is the fix. The flag makes Cluster Autoscaler track post-scale-up cooldowns per node group instead of cluster-wide, so a GPU pool adding capacity no longer resets the clock on an idle node in an unrelated memory pool. The default is&amp;nbsp;&lt;code&gt;false&lt;/code&gt;, which means every cluster running multiple ASGs has this behavior until someone turns it off.&lt;/p&gt;

&lt;p&gt;We hit this with Dagster. Jobs used separate pools for general work, large CPU and memory tasks, and GPU tasks, each with&amp;nbsp;&lt;code&gt;min_size = 0&lt;/code&gt;, so we expected an unused pool to disappear on its own schedule. It didn't. A job starting in one pool delayed scale-down in every other pool, and with jobs starting and ending throughout the day, nodes that were no longer needed stayed alive far longer than they should have.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why idle nodes stay running&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Take two node groups, one for memory-heavy batch jobs and one for GPU jobs, both with&amp;nbsp;&lt;code&gt;min_size = 0&lt;/code&gt;. At noon the last memory-heavy job completes, its node goes empty, and Cluster Autoscaler starts the normal waiting period before deletion. Ten minutes later that node has waited long enough - and at almost the same moment a GPU job appears, its pod can't schedule, and Cluster Autoscaler grows the GPU Auto Scaling Group. You'd expect the memory node to disappear now: it belongs to another group, has no useful work, and has already passed its own waiting period. It stays.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Memory group&lt;/th&gt;
&lt;th&gt;GPU group&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Last job ends&lt;/td&gt;
&lt;td&gt;Idle&lt;/td&gt;
&lt;td&gt;Memory node becomes unneeded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12:10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Node has waited 10 minutes&lt;/td&gt;
&lt;td&gt;New job triggers scale-up&lt;/td&gt;
&lt;td&gt;Global cooldown starts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12:11&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Node is eligible for deletion&lt;/td&gt;
&lt;td&gt;New node is starting&lt;/td&gt;
&lt;td&gt;Memory node cannot scale down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;12:20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Node has been idle 20 minutes&lt;/td&gt;
&lt;td&gt;Cooldown expires&lt;/td&gt;
&lt;td&gt;Deletion can proceed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One extra ten-minute wait doesn't look serious. Bursty workloads turn it into a loop: a CPU pool grows, then a GPU pool grows, then a general pool grows, and each event postpones unrelated scale-down work. The problem is easy to miss because every node group looks correctly configured - minimum size is zero, jobs finish, pods disappear, and the autoscaler still reports nodes as unneeded. Yet the EC2 instances stay active, and nothing in your Terraform or your Helm values looks wrong. We didn't identify the shared cooldown from configuration alone; it took finding upstream issue&amp;nbsp;#4872, which describes the same cross-group delay.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What Cluster Autoscaler actually tracks&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Cluster Autoscaler treats an AWS Auto Scaling Group as a Kubernetes node group and decides both when to grow that group and when nodes can be safely removed from it. Scale-down has two separate waiting mechanisms that are easy to confuse: one governs each node's eligibility, the other protects the cluster after recent scaling actions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scale-down-unneeded-time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long a node must remain unneeded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scale-down-delay-after-add&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;10m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long scale-down waits after scale-up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scale-down-delay-after-delete&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long scale-down waits after deletion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scale-down-delay-after-failure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long scale-down waits after failed scale-down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--scale-down-delay-type-local&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether post-action delays are per node group&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;scale-down-unneeded-time&lt;/code&gt;&amp;nbsp;answers "has this node been unnecessary for long enough?" - Cluster Autoscaler checks resource requests, movable pods, scheduling rules, PodDisruptionBudgets, and other safety conditions to decide. The&amp;nbsp;&lt;code&gt;scale-down-delay-after-*&lt;/code&gt;&amp;nbsp;settings answer a completely different question: "is scale-down allowed at all right now, given recent autoscaler activity?" A node can pass its unneeded timer while a cooldown still blocks deletion, and that's exactly what happens here.&amp;nbsp;&lt;strong&gt;Another node group scaling up does not erase the idle node's unneeded history - it adds a separate gate on top of it.&lt;/strong&gt;&amp;nbsp;Before 2024 that gate was always global, and with the default&amp;nbsp;&lt;code&gt;false&lt;/code&gt;&amp;nbsp;it still is. Support for per-group cooldowns arrived through&amp;nbsp;kubernetes/autoscaler#5729, which lets one process remember scaling activity per node group instead of treating every action as cluster-wide.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Enable per-node-group cooldowns&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Keep one Deployment managing all node groups and set the flag. If you install Cluster Autoscaler with Helm, the change belongs in your values file:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="na"&gt;autoDiscovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;clusterName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production-eks&lt;/span&gt;

&lt;span class="na"&gt;awsRegion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eu-west-1&lt;/span&gt;

&lt;span class="na"&gt;extraArgs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scale-down-delay-type-local&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
  &lt;span class="na"&gt;scale-down-delay-after-add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
  &lt;span class="na"&gt;scale-down-unneeded-time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A GPU group scale-up now starts a cooldown for the GPU group only, and an eligible node in the memory group can be removed while it runs. Keep the delay values at their defaults unless you have measurements that justify changing them - new nodes need time to register, start DaemonSets, advertise GPUs, and receive pending pods, and local scope fixes the isolation problem without removing that protection.&amp;nbsp;&lt;strong&gt;Setting&amp;nbsp;&lt;code&gt;scale-down-delay-after-add: 0&lt;/code&gt;&amp;nbsp;is not an equivalent shortcut.&lt;/strong&gt;&amp;nbsp;It does avoid cross-group delay, but it does so by removing the cooldown everywhere, including from the group that just added nodes, which is the one group that actually needs it.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Check your binary supports the flag&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The feature merged into the main branch in January 2024 and was backported to the 1.29 release branch that March through&amp;nbsp;#6484.&amp;nbsp;&lt;strong&gt;The original&amp;nbsp;&lt;code&gt;1.29.0&lt;/code&gt;&amp;nbsp;release does not include it, so do not assume every binary labeled 1.29 supports the flag.&lt;/strong&gt;&amp;nbsp;Check the actual image and the rendered command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get deployment cluster-autoscaler &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.template.spec.containers[0].image}{"\n"}'&lt;/span&gt;

kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get deployment cluster-autoscaler &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .spec.template.spec.containers[0].command[*]}{.}{"\n"}{end}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the logs contain&amp;nbsp;&lt;code&gt;unknown flag: --scale-down-delay-type-local&lt;/code&gt;, the image lacks the feature - upgrade the binary rather than quietly dropping the setting. That upgrade has a constraint of its own: Cluster Autoscaler simulates Kubernetes scheduling, so&amp;nbsp;&lt;strong&gt;its minor version must match your cluster's Kubernetes minor version&lt;/strong&gt;, and cross-version combinations are not supported. AWS repeats this in its&amp;nbsp;EKS guidance. One more trap sits between you and the right binary: Helm chart version and Cluster Autoscaler application version are different numbers. Inspect the chart's&amp;nbsp;&lt;code&gt;appVersion&lt;/code&gt;&amp;nbsp;or pin&amp;nbsp;&lt;code&gt;image.tag&lt;/code&gt;&amp;nbsp;explicitly, so a chart that looks current doesn't deploy a binary that isn't.&lt;/p&gt;

&lt;p&gt;Local cooldowns only isolate delays within a single Cluster Autoscaler process, which makes process ownership the next thing to confirm.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Make sure one autoscaler owns the ASGs&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The guarantee is worthless if a second, unmanaged autoscaler also watches some of your ASGs, or if one process never discovers all of them. On AWS, auto-discovery selects ASGs by tags, and the standard Helm values look for two keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;autoDiscovery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;clusterName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production-eks&lt;/span&gt;
  &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;k8s.io/cluster-autoscaler/enabled&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;k8s.io/cluster-autoscaler/{{ .Values.autoDiscovery.clusterName }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every ASG the autoscaler should own needs both keys attached before discovery picks it up. Tag values are ignored unless you include them in the discovery expression, but values like&amp;nbsp;&lt;code&gt;true&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;owned&lt;/code&gt;&amp;nbsp;make ownership readable and give you something to write IAM conditions against.&lt;/p&gt;

&lt;p&gt;Use separate node groups when capacity or scheduling actually differs, and give each one real labels, real taints, and scale limits that allow it to reach zero. With&amp;nbsp;&lt;code&gt;terraform-aws-modules/eks/aws&lt;/code&gt;&amp;nbsp;(&lt;code&gt;~&amp;gt; 21.0&lt;/code&gt;), the node group definition is the part that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;eks_managed_node_groups&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;instance_types&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"m7i.2xlarge"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="nx"&gt;min_size&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;   &lt;span class="c1"&gt;# allows the group to empty completely&lt;/span&gt;
    &lt;span class="nx"&gt;max_size&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
    &lt;span class="nx"&gt;desired_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="s2"&gt;"node-role"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"batch"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;taints&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;dedicated&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"dedicated"&lt;/span&gt;
        &lt;span class="nx"&gt;value&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"batch"&lt;/span&gt;
        &lt;span class="nx"&gt;effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NO_SCHEDULE"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ASG this creates is the resource your discovery tags must reach, and managed node groups generate ASG names you don't control. Module inputs and outputs also shift between versions, so verify the tags actually landed.&amp;nbsp;&lt;strong&gt;Tags on launch templates, security groups, or EC2 instances do not count&lt;/strong&gt;&amp;nbsp;- auto-discovery reads ASG tags and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws autoscaling describe-auto-scaling-groups &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'AutoScalingGroups[].{Name:AutoScalingGroupName,Tags:Tags[?starts_with(Key, `k8s.io/cluster-autoscaler/`)].{Key:Key,Value:Value}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your module doesn't attach the discovery tags, read the generated ASG name and attach them yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;batch_asg_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks_managed_node_groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"batch"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;node_group_resources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;autoscaling_groups&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;

  &lt;span class="nx"&gt;autoscaler_tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"k8s.io/cluster-autoscaler/enabled"&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt;
    &lt;span class="s2"&gt;"k8s.io/cluster-autoscaler/production-eks"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"owned"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_autoscaling_group_tag"&lt;/span&gt; &lt;span class="s2"&gt;"autoscaler"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;autoscaler_tags&lt;/span&gt;

  &lt;span class="nx"&gt;autoscaling_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batch_asg_name&lt;/span&gt;

  &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
    &lt;span class="nx"&gt;propagate_at_launch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;# this tag is for the ASG itself, not the instances it launches&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Do not combine static&amp;nbsp;&lt;code&gt;--nodes&lt;/code&gt;&amp;nbsp;arguments with auto-discovery.&lt;/strong&gt;&amp;nbsp;Mixing the two makes ownership hard to reason about and will eventually have you debugging the state of a node group the process was never managing. The&amp;nbsp;AWS provider documentation&amp;nbsp;recommends auto-discovery and warns against the combination. Discovery controls what the process can see; IAM controls what it can change. Use IRSA or EKS Pod Identity and constrain&amp;nbsp;&lt;code&gt;SetDesiredCapacity&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;TerminateInstanceInAutoScalingGroup&lt;/code&gt;&amp;nbsp;to ASGs carrying the cluster tags - AWS publishes a&amp;nbsp;tag-conditioned IAM example.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Scale node groups back up from zero&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Scale-to-zero introduces a different failure mode with the same symptom: a pool that sits at the wrong size and won't move. When a node group is empty, Kubernetes has no live Node object advertising that group's labels, taints, or custom resources, and Cluster Autoscaler still has to decide whether a pending pod would fit there. This bites hardest on pools selected by labels, protected by taints, or backed by GPUs.&lt;/p&gt;

&lt;p&gt;Cluster Autoscaler infers CPU, memory, and GPU capacity from AWS configuration, and for EKS managed node groups it can also read labels and taints through&amp;nbsp;&lt;code&gt;eks:DescribeNodegroup&lt;/code&gt;, including at zero nodes. If that covers everything your pod's scheduling rules reference, you may not need explicit node-template tags at all. When it doesn't, put the metadata on the ASG, where it describes the simulated node used for scale-from-zero decisions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_autoscaling_group_tag"&lt;/span&gt; &lt;span class="s2"&gt;"batch_label"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;autoscaling_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batch_asg_name&lt;/span&gt;

  &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"k8s.io/cluster-autoscaler/node-template/label/node-role"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"batch"&lt;/span&gt;
    &lt;span class="nx"&gt;propagate_at_launch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_autoscaling_group_tag"&lt;/span&gt; &lt;span class="s2"&gt;"batch_taint"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;autoscaling_group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batch_asg_name&lt;/span&gt;

  &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;key&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"k8s.io/cluster-autoscaler/node-template/taint/dedicated"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"batch:NoSchedule"&lt;/span&gt;  &lt;span class="c1"&gt;# value and effect packed into one string&lt;/span&gt;
    &lt;span class="nx"&gt;propagate_at_launch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Node-template tags do not configure real nodes.&lt;/strong&gt;&amp;nbsp;They describe a hypothetical node for the simulator, so the same labels and taints must also exist on the managed node group itself - the&amp;nbsp;&lt;code&gt;labels&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;taints&lt;/code&gt;&amp;nbsp;blocks shown earlier - and the pending pod must request that same contract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;node-role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch&lt;/span&gt;
  &lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dedicated&lt;/span&gt;
      &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Equal&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch&lt;/span&gt;
      &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NoSchedule&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example.com/data-job:2026-07-28&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;6Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Break that three-way match and you get one of two outcomes, both of which look like a stuck queue. Either the pool never scales up, because Cluster Autoscaler can't tell the pending pod would fit, or it scales up a node that still can't schedule the pod, because the real labels don't match what the pod asked for. Whichever source the metadata comes from, simulated and real must agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Diagnose and verify&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Start by confirming the nodes are genuinely idle from Cluster Autoscaler's perspective. Low observed CPU is not enough - Cluster Autoscaler reasons about pod resource requests and whether pods can move elsewhere, not utilization. Run it at verbosity&amp;nbsp;&lt;code&gt;4&lt;/code&gt;&amp;nbsp;while investigating and follow the Deployment logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system logs deployment/cluster-autoscaler &lt;span class="nt"&gt;--follow&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'unneeded|scale down|scaled up recently|removing'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You're looking for evidence that Cluster Autoscaler already considers the node removable, followed by a cooldown message instead of a deletion. Exact wording varies by version, but the pattern holds: one line marks a node unneeded or eligible, a later line skips scale-down because something scaled up recently. Compare those timestamps against ASG scaling activity in the&amp;nbsp;&lt;em&gt;other&lt;/em&gt;&amp;nbsp;group - a scale-up in group B shortly before an eligible node in group A fails to disappear is the signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws autoscaling describe-scaling-activities &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--auto-scaling-group-name&lt;/span&gt; &amp;lt;group-b-asg-name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-items&lt;/span&gt; 20

aws autoscaling describe-auto-scaling-groups &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'AutoScalingGroups[].{Name:AutoScalingGroupName,Desired:DesiredCapacity,Min:MinSize,Max:MaxSize}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule out the ordinary blockers before blaming the cooldown. A node legitimately stays when it hosts local storage, a restrictive PodDisruptionBudget, an unreplicated pod, or&amp;nbsp;&lt;code&gt;safe-to-evict: "false"&lt;/code&gt;. Scheduling constraints do the same: if the pods on that node can't fit anywhere else because of selectors, affinity, host ports, or resource requests, Cluster Autoscaler is keeping the node for good reason.&lt;/p&gt;

&lt;p&gt;Once the flag is deployed, confirm that exactly one Deployment manages the intended ASGs and that the rendered command carries the setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get deployments | &lt;span class="nb"&gt;grep &lt;/span&gt;cluster-autoscaler

kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get deployment cluster-autoscaler &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .spec.template.spec.containers[0].command[*]}{.}{"\n"}{end}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep &lt;/span&gt;scale-down-delay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run a controlled two-group test. Let a node in group A become unneeded, wait for the logs to show it as a candidate, and create a pod only group B can run - here a batch pool labeled&amp;nbsp;&lt;code&gt;node-role=batch&lt;/code&gt;&amp;nbsp;and tainted&amp;nbsp;&lt;code&gt;dedicated=batch:NoSchedule&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;force-batch-scale-up&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Never&lt;/span&gt;
  &lt;span class="na"&gt;nodeSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;node-role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch&lt;/span&gt;
  &lt;span class="na"&gt;tolerations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dedicated&lt;/span&gt;
      &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Equal&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;batch&lt;/span&gt;
      &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NoSchedule&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pause&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;public.ecr.aws/eks-distro/kubernetes/pause:3.10&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;6Gi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Group B's scale-up should no longer postpone the eligible node in group A. Deletion still depends on scan interval, drain time, AWS API latency, and the node staying unneeded throughout, so give it a couple of scan cycles before declaring the test failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Legacy: one autoscaler per ASG&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Before per-node-group cooldowns existed, sharding was the practical workaround: run one Cluster Autoscaler per ASG, each process watching a disjoint discovery tag. Each shard then kept its own cooldown state, so a GPU scale-up couldn't delay the process responsible for memory nodes.&lt;/p&gt;

&lt;p&gt;It's heavier than it looks. Every shard needs its own ASG ownership boundary, leader election, service account identity, and IAM scope, and the operational surface grows linearly with node group count. Worse, shards watch the same unschedulable pods without coordinating, so two autoscalers can each decide their ASG can help and add duplicate capacity for a single pending pod. AWS&amp;nbsp;recommends sharding only as a last resort, and the only case that still qualifies is a cluster where the binary matching your Kubernetes minor version predates the feature and the upgrade is blocked.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What's next&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Run one Cluster Autoscaler with&amp;nbsp;&lt;code&gt;--scale-down-delay-type-local&lt;/code&gt;&amp;nbsp;enabled, keep the default safety delays, and let them apply to the group that earned them. Verify three things afterward: one process owns every ASG you care about, the deployed binary actually accepts the flag, and any group that reaches zero has matching metadata across ASG node-template tags, real node configuration, and pod scheduling rules.&lt;/p&gt;

&lt;p&gt;Cluster Autoscaler's behavior shifts between releases, and flags have been added, renamed, and defaulted differently over time. Check the&amp;nbsp;AWS provider documentation&amp;nbsp;against the source tag matching your image before you ship, then test it with real workloads rather than a single pause pod.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>aws</category>
      <category>ai</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Postgres LISTEN/NOTIFY in Production: Safe Reconnects &amp; SSE</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Fri, 14 Aug 2026 07:24:09 +0000</pubDate>
      <link>https://dev.to/u11d/postgres-listennotify-in-production-safe-reconnects-sse-29hd</link>
      <guid>https://dev.to/u11d/postgres-listennotify-in-production-safe-reconnects-sse-29hd</guid>
      <description>&lt;p&gt;If your app needs to push live updates to connected clients — a job progress bar, a balance that changes when a background worker finishes, a "this record was deleted elsewhere" toast — the reflex is to reach for Redis Pub/Sub or a message broker. But if you already run PostgreSQL, it ships with a pub/sub primitive built in: &lt;code&gt;LISTEN&lt;/code&gt; / &lt;code&gt;NOTIFY&lt;/code&gt;. No extra infrastructure, no extra failure domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is Postgres LISTEN/NOTIFY?&lt;/strong&gt; It's a built-in pub/sub mechanism: one session runs &lt;code&gt;pg_notify(channel, payload)&lt;/code&gt; inside a transaction, and any other session that has issued &lt;code&gt;LISTEN &amp;lt;channel&amp;gt;&lt;/code&gt; on a persistent connection receives the payload once that transaction commits. There's no persistence and no delivery guarantee — it's a live signal, not a queue — so it fits best as a "something changed, go re-check" nudge rather than a system of record.&lt;/p&gt;

&lt;p&gt;This post walks through the implementation we actually run in production: a single shared LISTEN connection multiplexing many logical channels, wired into per-client Server-Sent Events (SSE) streams. Along the way: the reconnect logic and the race conditions that come with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Postgres LISTEN/NOTIFY works
&lt;/h2&gt;

&lt;p&gt;Postgres gives you two SQL-level building blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'job_status_123'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"status":"done"}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and, on a &lt;strong&gt;persistent&lt;/strong&gt; connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;LISTEN&lt;/span&gt; &lt;span class="n"&gt;job_status_123&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any session that has issued &lt;code&gt;LISTEN&lt;/code&gt; on a channel receives the payload once the transaction that ran &lt;code&gt;pg_notify&lt;/code&gt; commits, not the instant the call executes. Multiple identical notifications on the same channel/payload fired within one transaction get collapsed into one delivery. Several constraints shape everything that follows (see the Postgres NOTIFY reference for the authoritative spec):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Payload is capped at 8000 bytes. Fine for status pings, not for shipping large blobs.&lt;/li&gt;
&lt;li&gt;Channel names are Postgres identifiers, truncated at 63 characters. Patterns like &lt;code&gt;job_status_&amp;lt;uuid&amp;gt;&lt;/code&gt; need to fit under that limit or get silently truncated and collide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No persistence, no delivery guarantee.&lt;/strong&gt; If nobody is listening when &lt;code&gt;pg_notify&lt;/code&gt; fires, the message is gone. This is a live signal, not a queue: any client relying on it needs a way to independently reconcile state, e.g. re-fetch from the DB.&lt;/li&gt;
&lt;li&gt;The notification queue itself is bounded (8GB by default, per the Postgres async messaging docs). If a listening session sits idle inside an open transaction, it stops draining the queue for everyone, and &lt;code&gt;pg_notify&lt;/code&gt; starts erroring once the queue fills. Keep LISTEN sessions out of long-running transactions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because &lt;code&gt;LISTEN&lt;/code&gt; requires a session-scoped, long-lived connection, and most apps talk to Postgres through a connection pool, you can't just &lt;code&gt;LISTEN&lt;/code&gt; from your normal query pool. Pooled connections get recycled and your subscription vanishes with them. You need one connection dedicated to listening, held open for the life of the process.&lt;/p&gt;

&lt;p&gt;This also rules out transaction-mode poolers like pgbouncer or RDS Proxy for the LISTEN connection specifically. They can hand your session's underlying socket to a different client between transactions, which silently kills the subscription. The dedicated LISTEN connection needs to bypass the pooler and go straight to Postgres. If you run multiple app instances, each one holds its own LISTEN connection and each receives every NOTIFY independently, which is usually what you want: every instance can push updates to its own locally-connected clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  One connection, many channels
&lt;/h2&gt;

&lt;p&gt;Rather than open a Postgres connection per subscriber, a single physical &lt;code&gt;LISTEN&lt;/code&gt; connection can multiplex an arbitrary number of logical channels. In-process, that's just a map from channel name to a set of callbacks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotificationHub&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;postgres&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;listeners&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MessageCallback&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;unlistenFns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection itself is configured to never idle out and to identify itself for observability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;getAppConnectionConfig&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;idle_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;connect_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app-listen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;onclose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* ... */&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pool is deliberately capped at one connection: it exists purely to host a dedicated LISTEN session, not to serve queries. Mixing it into a general-purpose pool would mean losing subscriptions every time the pool recycles a connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling reconnects: capped exponential backoff
&lt;/h2&gt;

&lt;p&gt;A long-lived TCP connection to a database will eventually drop: network blips, database failover, load balancer idle timeouts. The interesting design question isn't "will it disconnect" but "what happens to every registered channel when it does."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;onclose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;dbLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LISTEN connection closed unexpectedly&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;disconnected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleReconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reconnect uses a capped exponential backoff so a flapping database doesn't get hammered with reconnect attempts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_DELAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;scheduleReconnect&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delayIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectAttempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_DELAYS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_DELAYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;delayIndex&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectAttempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reconnectAttempt&lt;/code&gt; resets to 0 on a successful &lt;code&gt;connect()&lt;/code&gt;, so backoff only escalates across consecutive failures. A single flaky reconnect doesn't leave future reconnects artificially delayed.&lt;/p&gt;

&lt;p&gt;On reconnect, every channel that had at least one active subscriber gets re-&lt;code&gt;LISTEN&lt;/code&gt;ed from scratch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failedChannels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listenToChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;failedChannels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failedChannels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;disconnected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleReconnect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the all-or-nothing treatment: if even one channel fails to re-&lt;code&gt;LISTEN&lt;/code&gt;, the whole reconnect is considered failed and retried. A hub that's "half connected," silently missing notifications on some channels, is worse than one that's visibly disconnected and retrying. At least then the failure isn't invisible to callers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unsubscribe race, solved by ordering, not locking
&lt;/h2&gt;

&lt;p&gt;Multiple parts of an app can subscribe to the same channel concurrently (two browser tabs watching the same job, for instance). The tricky moment is when the &lt;em&gt;last&lt;/em&gt; subscriber for a channel unsubscribes at the same time a &lt;em&gt;new&lt;/em&gt; subscriber for that same channel is arriving:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unlisten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unlistenFns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// Delete before awaiting so a concurrent subscribe() for this&lt;/span&gt;
      &lt;span class="c1"&gt;// channel calls listenToChannel() again. PostgreSQL silently&lt;/span&gt;
      &lt;span class="c1"&gt;// ignores a duplicate LISTEN on the same connection, so the race&lt;/span&gt;
      &lt;span class="c1"&gt;// is harmless — the hub will have an active LISTEN either way.&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unlistenFns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unlisten&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeoutUnlisten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unlisten&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* timed out or connection gone — safe to ignore */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The naive version would &lt;code&gt;await UNLISTEN&lt;/code&gt; first, then clean up bookkeeping. But between "decide to unlisten" and "UNLISTEN finishes," a new subscriber could arrive, see the channel already has a bookkeeping entry, and skip re-&lt;code&gt;LISTEN&lt;/code&gt;ing. That leaves the hub with no active &lt;code&gt;LISTEN&lt;/code&gt; for a channel it thinks it's subscribed to.&lt;/p&gt;

&lt;p&gt;By deleting the map entry &lt;em&gt;before&lt;/em&gt; awaiting &lt;code&gt;UNLISTEN&lt;/code&gt;, a concurrent &lt;code&gt;subscribe()&lt;/code&gt; call always sees "no entry" and issues its own &lt;code&gt;LISTEN&lt;/code&gt; unconditionally. Postgres treats a duplicate &lt;code&gt;LISTEN&lt;/code&gt; on the same session as a no-op, so the worst case is two harmless &lt;code&gt;LISTEN&lt;/code&gt; calls in flight instead of a silently dropped subscription. When you can't easily lock across an async boundary, look for an operation idempotent enough to make the race safe by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Callers should never worry about connection state
&lt;/h2&gt;

&lt;p&gt;The public &lt;code&gt;subscribe()&lt;/code&gt; API registers callbacks unconditionally, regardless of whether the underlying Postgres connection is currently up. It only rejects if an opportunistic &lt;code&gt;LISTEN&lt;/code&gt; call fails outright while attempting an already-connected session, and that case is recovered by the reconnect logic on its next attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MessageCallback&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Subscription&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Register callbacks regardless of connection state&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listenToChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// no-op if already LISTENing&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;disconnected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connecting&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Callbacks are registered in memory first; the actual &lt;code&gt;LISTEN&lt;/code&gt; happens opportunistically if connected, or gets picked up automatically on the next successful reconnect. Every consumer of the hub stays decoupled from the connection's health. They just subscribe, and messages show up when the pipe is open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into Server-Sent Events
&lt;/h2&gt;

&lt;p&gt;The hub by itself is transport-agnostic; a thin SSE layer turns it into per-client streams with keep-alives and cleanup on disconnect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReadableStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Subscription&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onAbort&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onAbort&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;keepAliveInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`: ping\n\n`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;keepAliveMs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscribe&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;isClosed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="nx"&gt;unsubscribeState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;onHubStateChange&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="s2"&gt;`data: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;liveUpdates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="nx"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isClosed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="nf"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;

      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onAfterSubscribe&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onAfterSubscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cleanupFn&lt;/span&gt;&lt;span class="p"&gt;?.();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth calling out.&lt;/p&gt;

&lt;p&gt;The hub's connection state is surfaced to the client. When &lt;code&gt;onHubStateChange&lt;/code&gt; reports &lt;code&gt;"disconnected"&lt;/code&gt;, an &lt;code&gt;{"type":"info","liveUpdates":false}&lt;/code&gt; frame goes out over SSE so the UI can show "reconnecting..." instead of silently going stale. Since NOTIFY gives no delivery guarantee, telling the client when it might be missing updates is almost as important as delivering the updates themselves.&lt;/p&gt;

&lt;p&gt;There's also a read-then-subscribe race window. &lt;code&gt;onStart&lt;/code&gt; typically does an initial DB read to send current state, and only afterwards does the code call &lt;code&gt;subscribe()&lt;/code&gt; to start receiving live updates. If a &lt;code&gt;NOTIFY&lt;/code&gt; fires in the gap between that read and the &lt;code&gt;LISTEN&lt;/code&gt; actually being established, it's lost for good: the classic "check-then-watch" race in any pub/sub system. The &lt;code&gt;onAfterSubscribe&lt;/code&gt; hook closes that window. After the subscription is confirmed active, it re-checks state one more time, so any update that landed in the gap gets picked up on the second read instead of being silently missed. The client-side handler needs to tolerate receiving the same update twice (once from the catch-up read, once from a live NOTIFY that arrives moments later). Treat updates as idempotent, not as a strictly-once stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this pattern fits
&lt;/h2&gt;

&lt;p&gt;Postgres LISTEN/NOTIFY works well when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Update volume is modest (dashboards, job status, per-user notifications), not a high-throughput event bus.&lt;/li&gt;
&lt;li&gt;Clients treat NOTIFY as a nudge to re-sync, not as the sole source of truth. Always have a path to reconstruct state via a normal query if a notification is missed.&lt;/li&gt;
&lt;li&gt;You want one fewer piece of infrastructure to run and monitor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's the wrong tool when you need guaranteed delivery, durable queues, replay, or very high fan-out. That's what a real message broker is for. But for "tell connected clients something changed," Postgres has had a message bus built in the whole time.&lt;/p&gt;

</description>
      <category>postgres</category>
    </item>
    <item>
      <title>Keycloak at 100% CPU After Deploy: Root Cause and Fix</title>
      <dc:creator>Bartek Gałęzowski</dc:creator>
      <pubDate>Fri, 14 Aug 2026 07:08:30 +0000</pubDate>
      <link>https://dev.to/u11d/keycloak-at-100-cpu-after-deploy-root-cause-and-fix-52dn</link>
      <guid>https://dev.to/u11d/keycloak-at-100-cpu-after-deploy-root-cause-and-fix-52dn</guid>
      <description>&lt;h2&gt;
  
  
  The Symptom: Recognizing This CPU Spike
&lt;/h2&gt;

&lt;p&gt;You deploy a containerized Keycloak instance — even the official &lt;code&gt;quay.io/keycloak/keycloak&lt;/code&gt; image, unmodified — to a managed container platform, and within seconds CPU utilization climbs to 100% and stays there — regardless of traffic. No requests are being served, no realm imports are running, and nothing in the application logs looks wrong. Health checks pass. HTTP endpoints respond normally.&lt;/p&gt;

&lt;p&gt;This pattern shows up most often on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DigitalOcean App Platform&lt;/li&gt;
&lt;li&gt;Google Cloud Run&lt;/li&gt;
&lt;li&gt;AWS ECS Fargate&lt;/li&gt;
&lt;li&gt;Heroku&lt;/li&gt;
&lt;li&gt;Self-managed or managed Kubernetes clusters without a multicast-aware CNI&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Root Cause: Infinispan, JGroups, and the Multicast Problem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Default: Distributed Caching via Infinispan
&lt;/h3&gt;

&lt;p&gt;By default, Keycloak ships with a distributed cache mode built on Infinispan, an embedded data grid used to replicate session state, authentication sessions, and other transient data across cluster nodes. This is the &lt;code&gt;ispn&lt;/code&gt; cache provider, and it's the default unless explicitly overridden — either at build time (&lt;code&gt;kc.sh build --cache=&amp;lt;mode&amp;gt;&lt;/code&gt;) or at runtime (&lt;code&gt;KC_CACHE&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Why JGroups Can't Discover Peers
&lt;/h3&gt;

&lt;p&gt;Infinispan's clustering layer relies on JGroups, a toolkit for reliable group communication. JGroups needs a discovery protocol to find other cluster members, and out of the box, Keycloak's default JGroups stack uses UDP-based discovery (&lt;code&gt;PING&lt;/code&gt;/&lt;code&gt;MPING&lt;/code&gt;), which depends on IP multicast to broadcast "who else is out there?" messages across the network.&lt;/p&gt;

&lt;p&gt;Managed container platforms don't expose multicast-capable networking. Their networking layers are software-defined, ephemeral, and built around dynamic scheduling of containers across shared infrastructure — a model that's fundamentally incompatible with IP multicast, which assumes a flat, broadcast-capable network segment. This holds almost universally: PaaS offerings, serverless container runtimes, and even many managed Kubernetes configurations without a multicast-aware CNI all impose the same restriction.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Result: A Silent, Load-Independent CPU Burn
&lt;/h3&gt;

&lt;p&gt;JGroups doesn't fail gracefully when discovery is unreachable. Instead, it enters an aggressive retry loop — broadcasting discovery packets, waiting for a timeout, retrying, re-forming view negotiations, and repeating indefinitely. Each cycle consumes CPU on packet construction, timeout scheduling, and thread contention, multiplied across the JGroups protocol stack (transport, discovery, failure detection, and flow control protocols all layered on top of one another).&lt;/p&gt;

&lt;p&gt;None of this is visible from an application standpoint. A background thread pool is perpetually spinning, trying to solve a problem — finding cluster peers via multicast — that's structurally impossible in this environment. The result is sustained, load-independent CPU burn with no connection to request volume.&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;KC_CACHE=local&lt;/code&gt; eliminates the problem — not by fixing discovery, but by disabling clustering altogether. In local mode, Keycloak uses in-memory, non-replicated caches with no Infinispan clustering, no JGroups stack, and therefore no discovery loop to fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why It Happens After Redeploys, Not on First Boot
&lt;/h2&gt;

&lt;h3&gt;
  
  
  First Boot: A Clean, Uncontested Singleton
&lt;/h3&gt;

&lt;p&gt;On the very first deploy, exactly one container exists. When JGroups' discovery protocol fires, it queries whatever peer list mechanism is configured — raw UDP multicast, or more commonly on managed platforms, a DNS-based or static-host discovery protocol that resolves the platform's internal service name to the currently running container IPs. Since this is the first and only container, that resolution returns exactly one address: itself. There's nothing to negotiate and no peer to merge with. JGroups times out once on the discovery request, finds no one else, and installs itself as a singleton cluster view — a cheap, one-shot operation that doesn't manifest as sustained CPU load.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redeploy or Restart: A Two-Node Race That Never Resolves
&lt;/h3&gt;

&lt;p&gt;A redeploy or restart on most managed platforms is a rolling operation, not an instant swap. The platform starts the new container before fully tearing down the old one, to avoid downtime. For a short window, both the outgoing and incoming containers are alive simultaneously, and depending on how peer discovery is configured, both may be resolvable under the same internal service identity.&lt;/p&gt;

&lt;p&gt;This is where things diverge from the first-boot case: JGroups on the new node now believes there is another cluster member to reach. It attempts to establish a channel, negotiate a merged view, and start heartbeat-based failure detection with that peer — but the platform's networking still doesn't permit the actual cache-cluster transport traffic between containers (multicast is blocked, and often the cache port isn't even routed between sibling containers in the first place). The handshake can never complete: the peer is &lt;em&gt;addressable&lt;/em&gt; enough to be discovered, but not &lt;em&gt;reachable&lt;/em&gt; enough to actually communicate.&lt;/p&gt;

&lt;p&gt;The result is a protocol deadlock rather than a clean failure. JGroups cycles through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;periodic heartbeat attempts to the discovered peer&lt;/li&gt;
&lt;li&gt;failure-suspicion timers when heartbeats go unanswered&lt;/li&gt;
&lt;li&gt;merge-coordinator election attempts to decide which node's view should win&lt;/li&gt;
&lt;li&gt;state-transfer request/response handshakes that time out without completing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each sub-protocol retries independently and indefinitely, because there's no terminal "peer confirmed dead, drop it forever" signal — the old container is still technically registered as existing from the discovery layer's point of view, even though it's unreachable at the transport layer. This composite retry storm is considerably more CPU-intensive than the one-shot singleton bootstrap on first boot, which is why the spike reliably appears after a redeploy or restart, but not on the initial one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Fix It: Step-by-Step
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confirm you're hitting this issue.&lt;/strong&gt; Check CPU metrics with no active traffic — a sustained 100% with zero requests is the signature. Confirm &lt;code&gt;KC_CACHE&lt;/code&gt; is unset or set to &lt;code&gt;ispn&lt;/code&gt; (the default).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide whether you actually need clustering.&lt;/strong&gt; If you're running a single Keycloak instance, which covers most small and mid-sized deployments, you don't need Infinispan's distributed cache at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single instance → set &lt;code&gt;KC_CACHE=local&lt;/code&gt;.&lt;/strong&gt; Add it as a runtime environment variable, or bake it in at build time with &lt;code&gt;-cache=local&lt;/code&gt;. Redeploy and confirm CPU returns to baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple instances / HA → switch to &lt;code&gt;JDBC_PING&lt;/code&gt;.&lt;/strong&gt; Build with &lt;code&gt;-cache-stack=jdbc-ping&lt;/code&gt; (or supply a custom &lt;code&gt;cache-ispn.xml&lt;/code&gt;), pointing at your existing database. This keeps session replication working without depending on multicast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify after a redeploy, not just first boot.&lt;/strong&gt; Because the issue specifically reproduces on rolling restarts, don't declare victory after the first boot alone — trigger a redeploy and confirm CPU stays flat afterward too.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Trade-Offs of KC_CACHE=local
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;KC_CACHE=local&lt;/code&gt; is a valid and common fix, but it comes with real trade-offs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No safe horizontal scaling.&lt;/strong&gt; Run more than one replica behind a load balancer, and users can be routed to a different instance mid-session — silently logged out, losing SSO state, or hitting inconsistent authorization decisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restarts are lossy.&lt;/strong&gt; A container restart — which managed platforms perform routinely during deploys, scaling events, or health-check failures — wipes all in-memory session state for that instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a single-instance deployment, none of this is a compromise — there's no cluster to form in the first place, so disabling clustering is simply correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Need High Availability? Use JDBC_PING Instead
&lt;/h2&gt;

&lt;p&gt;If horizontal scaling or high availability is a requirement, the architecturally correct fix isn't to disable clustering — it's to replace the discovery mechanism with one that doesn't depend on multicast.&lt;/p&gt;

&lt;p&gt;Keycloak, via Infinispan/JGroups, supports a &lt;code&gt;JDBC_PING&lt;/code&gt; discovery protocol that uses your existing relational database as a shared coordination point: each node writes its cluster address to a database table, and peers discover each other by querying that table instead of broadcasting UDP packets.&lt;/p&gt;

&lt;p&gt;This is configured via a custom JGroups stack — &lt;code&gt;--cache-stack=jdbc-ping&lt;/code&gt; at build time, or a custom &lt;code&gt;cache-ispn.xml&lt;/code&gt; descriptor — and it works reliably on any managed platform, regardless of vendor, because it never depends on network-layer broadcast at all. The cost is a small, constant amount of additional load on your database (heartbeat writes/reads at a configurable interval) in exchange for genuine session replication across nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  KC_CACHE Settings: Quick Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Recommended Setting&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single Keycloak instance, no plans to scale horizontally&lt;/td&gt;
&lt;td&gt;&lt;code&gt;KC_CACHE=local&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No cluster to form — disables Infinispan/JGroups entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple instances, need session replication / HA&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KC_CACHE=ispn&lt;/code&gt; with &lt;code&gt;--cache-stack=jdbc-ping&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Keeps clustering, swaps multicast discovery for database-backed discovery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple instances, occasional failover session loss acceptable (rare)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KC_CACHE=ispn&lt;/code&gt; with default stack&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Not recommended&lt;/strong&gt; on managed platforms — reproduces the CPU issue&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>keycloak</category>
      <category>infinispan</category>
      <category>jgroups</category>
    </item>
    <item>
      <title>Faster Geospatial Animations with PMTiles: Multi-Layer MVT Time Series in OpenLayers</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Wed, 08 Jul 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/faster-geospatial-animations-with-pmtiles-multi-layer-mvt-time-series-in-openlayers-30d6</link>
      <guid>https://dev.to/u11d/faster-geospatial-animations-with-pmtiles-multi-layer-mvt-time-series-in-openlayers-30d6</guid>
      <description>&lt;p&gt;Most people try to animate time series by stacking separate tile sources or flipping CSS styles mid-frame. That causes flicker and a surprising amount of wasted CPU/GPU work. The core takeaway is simple: bake your timesteps into a single vector-tile archive (PMTiles) with per-timestep named layers, load it once, and drive animation by re-styling already-decoded tiles.&lt;/p&gt;

&lt;p&gt;I tested this with OpenLayers and noticed the difference right away: playback became smoother and far less CPU-bound once tiles were pre-baked and cached. The hard part is server-side — how you generate, package, and serve tiles matters more than which client library you pick.&lt;/p&gt;

&lt;p&gt;When you animate a map by switching between separate tile sources, each frame causes one of two expensive operations: a network fetch of new tiles, or client-side re-decoding and re-styling. Baking timesteps into one archive avoids that by turning each frame into a cheap style re-evaluation on already-decoded features.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architecture: single archive, dynamic layers
&lt;/h2&gt;

&lt;p&gt;MVT (Mapbox Vector Tile) is a compact protobuf-based vector tile format that encodes geometry and attributes per z/x/y tile. PMTiles packages MVT tiles into a single file for efficient distribution and partial HTTP range retrieval.&lt;/p&gt;

&lt;p&gt;The key workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Server side&lt;/strong&gt; — each timestep is a separate GeoJSON file. &lt;code&gt;tippecanoe&lt;/code&gt; joins them into one PMTiles archive, giving each timestep its own named MVT layer (&lt;code&gt;timestep_00&lt;/code&gt;, &lt;code&gt;timestep_01&lt;/code&gt;, …).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client side&lt;/strong&gt; — one &lt;code&gt;VectorTileSource&lt;/code&gt; loads the archive once. One &lt;code&gt;VectorTileLayer&lt;/code&gt; uses a style function that reads a mutable &lt;code&gt;currentLayerName&lt;/code&gt; variable. Advancing a frame means: update the variable, call &lt;code&gt;layer.changed()&lt;/code&gt;. OpenLayers re-runs the style function on the already-decoded tile features and repaints — no network request, no re-decode.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Complete working example
&lt;/h2&gt;

&lt;p&gt;!animation.gif&lt;/p&gt;

&lt;p&gt;This section contains everything needed to reproduce the demo from scratch: 11 per-timestep GeoJSON files, a tile generation script, and the client HTML.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Per-timestep GeoJSON files
&lt;/h3&gt;

&lt;p&gt;Create 11 files, one per timestep. Each contains a single large polygon with a &lt;code&gt;value&lt;/code&gt; property. The temporal identity comes from the file name (and the MVT layer name tippecanoe will assign), not from a property on the feature.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;0 10&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;pad&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%02d"&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;i &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"square_t&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pad&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.geojson"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
{
  "type": "FeatureCollection",
  "features": [{
    "type": "Feature",
    "geometry": {
      "type": "Polygon",
      "coordinates": [[[-60,-50],[60,-50],[60,50],[-60,50],[-60,-50]]]
    },
    "properties": { "value": &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt; }
  }]
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces &lt;code&gt;square_t00.geojson&lt;/code&gt; (value=0) through &lt;code&gt;square_t10.geojson&lt;/code&gt; (value=100).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Generate PMTiles with named layers (&lt;code&gt;generate-tiles.sh&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASH_SOURCE&lt;/span&gt;&lt;span class="p"&gt;[0]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

tippecanoe &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_00:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t00.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_01:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t01.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_02:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t02.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_03:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t03.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_04:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t04.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_05:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t05.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_06:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t06.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_07:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t07.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_08:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t08.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_09:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t09.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_10:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t10.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square.pmtiles"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-z&lt;/span&gt; 8 &lt;span class="nt"&gt;-Z&lt;/span&gt; 0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-simplification&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-tile-size-limit&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-feature-limit&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run with: &lt;code&gt;bash generate-tiles.sh&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Start the server
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4 — Client animation (&lt;code&gt;index.html&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;The full self-contained HTML file. No build step, no npm install — all dependencies come from &lt;code&gt;esm.sh&lt;/code&gt;, which rewrites bare module specifiers (like &lt;code&gt;pbf&lt;/code&gt; imported inside &lt;code&gt;ol/format/MVT&lt;/code&gt;) to absolute CDN URLs that the browser can resolve.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"UTF-8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1.0"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;MVT Animation&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;https://cdn.jsdelivr.net/npm/ol@10.9.0/ol.css&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;box-sizing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;border-box&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;monospace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#111&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#eee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
           &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;flex-direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100vh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#map&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#hud&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt; &lt;span class="m"&gt;20px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1a1a1a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#hud&lt;/span&gt; &lt;span class="nt"&gt;label&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;13px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#aaa&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#hud&lt;/span&gt; &lt;span class="nt"&gt;span&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#timestep-val&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#7af&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#value-val&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fa7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#color-swatch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;28px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;28px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#555&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#progress-bar&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#progress-fill&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nb"&gt;right&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#00f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#f00&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#controls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#eee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#555&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
             &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5px&lt;/span&gt; &lt;span class="m"&gt;14px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;13px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#444&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#loading&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;inset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
               &lt;span class="nl"&gt;justify-content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
               &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;999&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#loading&lt;/span&gt;&lt;span class="nc"&gt;.hidden&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"map"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"loading"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Loading tiles…&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"hud"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&amp;lt;label&amp;gt;&lt;/span&gt;Timestep &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"timestep-val"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&amp;lt;label&amp;gt;&lt;/span&gt;Value &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"value-val"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"display:flex;align-items:center;gap:8px;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;label&amp;gt;&lt;/span&gt;Color &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"color-swatch"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"progress-bar"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"progress-fill"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"width:0%"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"controls"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"btn-playpause"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Pause&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"btn-reset"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Reset&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;              &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/Map&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;View&lt;/span&gt;             &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/View&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;TileLayer&lt;/span&gt;        &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/layer/Tile&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OSM&lt;/span&gt;              &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/source/OSM&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;VectorTileLayer&lt;/span&gt;  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/layer/VectorTile&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;VectorTileSource&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/source/VectorTile&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;MVT&lt;/span&gt;              &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/format/MVT&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Style&lt;/span&gt;            &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/style/Style&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Fill&lt;/span&gt;             &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/style/Fill&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stroke&lt;/span&gt;           &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/style/Stroke&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fromLonLat&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/ol@10.9.0/proj&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PMTiles&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;      &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;https://esm.sh/pmtiles@4.4.1&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_00&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_02&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_03&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_04&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_05&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_06&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_07&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_08&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_09&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_10&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VALUES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STYLES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Fill&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`rgb(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;,0,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="na"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stroke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#fff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;archive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PMTiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;square.pmtiles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;VectorTileSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MVT&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;square.pmtiles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;maxZoom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;squareLayer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;VectorTileLayer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;layer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
          &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;STYLES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;map&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TileLayer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OSM&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;squareLayer&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;center&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fromLonLat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="na"&gt;zoom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FRAME_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;intervalId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;squareLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;FRAME_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intervalId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;intervalId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="nx"&gt;squareLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="nf"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
          &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;FRAME_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timestepEl&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep-val&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valueEl&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value-val&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;swatchEl&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;color-swatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;progressFill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;progress-fill&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;layerName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;layerName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;timestepEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;layerName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;valueEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="nx"&gt;swatchEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;STYLES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;getFill&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getColor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;progressFill&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loadingEl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startWhenReady&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;loadingEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;player&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tileloadend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startWhenReady&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rendercomplete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startWhenReady&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startWhenReady&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;btn-playpause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;playing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Play&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;btn-reset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;playing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;btn-playpause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Running the full setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Install tippecanoe (macOS)&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;tippecanoe

&lt;span class="c"&gt;# 2. Create the 11 per-timestep GeoJSON files&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;0 10&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;pad&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%02d"&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;i &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-60,-50],[60,-50],[60,50],[-60,50],[-60,-50]]]},"properties":{"value":'&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="s1"&gt;'}}]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; square_t&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pad&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;.geojson
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# 3. Generate the PMTiles archive&lt;/span&gt;
bash generate-tiles.sh

&lt;span class="c"&gt;# 4. Start the server&lt;/span&gt;
npx serve

&lt;span class="c"&gt;# 5. Open the demo&lt;/span&gt;
open &amp;lt;http://localhost:3000/index.html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Conclusion: paint time, not flip it
&lt;/h2&gt;

&lt;p&gt;Don't stack separate tile sources for time-series animation. Bake timesteps into a single PMTiles archive as named MVT layers, load it once, and drive playback by updating a closure variable and calling &lt;code&gt;layer.changed()&lt;/code&gt;. Serve the archive with &lt;code&gt;npx serve&lt;/code&gt;. Start animation only after the first tile arrives.&lt;/p&gt;

&lt;p&gt;The result: smooth playback from first load, no flicker on hard refresh, no blank map with cache disabled.&lt;/p&gt;

</description>
      <category>gis</category>
      <category>openlayers</category>
      <category>datavisualization</category>
      <category>geospatial</category>
    </item>
    <item>
      <title>Argo CD GitLab Authentication: A Complete GitOps-Friendly Setup</title>
      <dc:creator>Paweł Swiridow</dc:creator>
      <pubDate>Wed, 01 Jul 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/argo-cd-gitlab-authentication-a-complete-gitops-friendly-setup-1ffe</link>
      <guid>https://dev.to/u11d/argo-cd-gitlab-authentication-a-complete-gitops-friendly-setup-1ffe</guid>
      <description>&lt;p&gt;Argo CD can use GitLab as an authentication provider, so users log in with their GitLab accounts instead of local Argo CD users. That gives you centralized identity, cleaner access control, and one less password database waiting to become a security incident with a logo.&lt;/p&gt;

&lt;p&gt;The setup uses OIDC through Dex, stores the GitLab OAuth credentials as encrypted Kubernetes secrets with SOPS and age, and lets Argo CD decrypt them with KSOPS during reconciliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How authentication works&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Argo CD does not talk to GitLab directly for user login. It uses Dex as an identity broker.&lt;/p&gt;

&lt;p&gt;The flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User -&amp;gt; Argo CD -&amp;gt; Dex -&amp;gt; GitLab -&amp;gt; Dex -&amp;gt; Argo CD -&amp;gt; RBAC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The user opens Argo CD.&lt;/li&gt;
&lt;li&gt;Argo CD redirects the user to Dex.&lt;/li&gt;
&lt;li&gt;Dex redirects the user to GitLab.&lt;/li&gt;
&lt;li&gt;GitLab authenticates the user and returns OIDC claims.&lt;/li&gt;
&lt;li&gt;Argo CD reads those claims and applies RBAC rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Authentication tells Argo CD who the user is. RBAC tells Argo CD what damage they are allowed to do. Ideally, not much.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Create a GitLab OAuth application&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;First, create an OAuth application in GitLab.&lt;/p&gt;

&lt;p&gt;In GitLab, go to your user, group, or admin application settings and create a new application with these values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Argo CD&lt;/span&gt;
&lt;span class="na"&gt;Redirect URI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://argocd.example.com/api/dex/callback&lt;/span&gt;
&lt;span class="na"&gt;Scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openid, read_user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitLab gives you two important values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;Application ID&lt;/span&gt;
&lt;span class="s"&gt;Secret&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Argo CD Dex config, these become:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;dex.gitlab.clientID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;gitlab-application-id&amp;gt;"&lt;/span&gt;
&lt;span class="na"&gt;dex.gitlab.clientSecret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;gitlab-application-secret&amp;gt;"&lt;/span&gt;
&lt;span class="na"&gt;dex.gitlab.redirectURI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://argocd.example.com/api/dex/callback"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not commit the secret in plain YAML. That is not GitOps. That is just leaving your keys under the doormat and calling it automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Encrypt the Argo CD secret with SOPS and age&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Use SOPS with age to encrypt the Kubernetes secret before committing it.&lt;/p&gt;

&lt;p&gt;Generate an age key if you do not have one yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;age-keygen &lt;span class="nt"&gt;-o&lt;/span&gt; age.key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the public key in&amp;nbsp;&lt;code&gt;.sops.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;creation_rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;encrypted_regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^(data|stringData)$'&lt;/span&gt;
    &lt;span class="na"&gt;age&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;age1examplepublickeyreplacewithyourrealpublickey&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create&amp;nbsp;&lt;code&gt;argocd-secret.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Secret&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd-secret&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
&lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Opaque&lt;/span&gt;
&lt;span class="na"&gt;stringData&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dex.gitlab.clientID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;gitlab-application-id&amp;gt;"&lt;/span&gt;
  &lt;span class="na"&gt;dex.gitlab.clientSecret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;gitlab-application-secret&amp;gt;"&lt;/span&gt;
  &lt;span class="na"&gt;dex.gitlab.redirectURI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://argocd.example.com/api/dex/callback"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Encrypt it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sops &lt;span class="nt"&gt;--encrypt&lt;/span&gt; argocd-secret.yml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; argocd-secret.sops.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then remove the unencrypted file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm &lt;/span&gt;argocd-secret.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, really remove it. Git has a long memory and an even longer blame history.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Add KSOPS to the Argo CD repo server&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Argo CD does not decrypt SOPS files by default. To make this work with Kustomize, install the KSOPS plugin in the Argo CD repo server.&lt;/p&gt;

&lt;p&gt;The plugin lives here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://github.com/viaduct-ai/kustomize-sops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you deploy Argo CD with Helm, add this to your values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;repoServer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
      &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/local/bin/kustomize&lt;/span&gt;
      &lt;span class="na"&gt;subPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
      &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/local/bin/ksops&lt;/span&gt;
      &lt;span class="na"&gt;subPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ksops&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
      &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
  &lt;span class="na"&gt;initContainers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;install-ksops&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;viaductoss/ksops:v4.5.1&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;[&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/ksops"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;install"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--with-kustomize"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/custom-tools"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
        &lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/custom-tools&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This installs&amp;nbsp;&lt;code&gt;ksops&lt;/code&gt;&amp;nbsp;and a Kustomize binary with plugin support into the repo server container.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Provide the age private key to Argo CD&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The repo server needs the age private key to decrypt the SOPS file.&lt;/p&gt;

&lt;p&gt;Create a Kubernetes secret from your age key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd create secret generic sops-age-key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;age.key&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expose it to the repo server as&amp;nbsp;&lt;code&gt;SOPS_AGE_KEY&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;repoServer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SOPS_AGE_KEY&lt;/span&gt;
      &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;secretKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sops-age-key&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;key&lt;/span&gt;
  &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
      &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/local/bin/kustomize&lt;/span&gt;
      &lt;span class="na"&gt;subPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
      &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/usr/local/bin/ksops&lt;/span&gt;
      &lt;span class="na"&gt;subPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ksops&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;custom-tools&lt;/span&gt;
      &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Protect this key properly. Anyone with the private key can decrypt the secrets. Kubernetes secrets are base64 encoded, not magically protected by tiny YAML elves.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Create the KSOPS generator&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Create&amp;nbsp;&lt;code&gt;secret-generator.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;viaduct.ai/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ksops&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd-secret-generator&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;config.kubernetes.io/function&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;exec:&lt;/span&gt;
        &lt;span class="s"&gt;path: ksops&lt;/span&gt;
&lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;argocd-secret.sops.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Kustomize to call&amp;nbsp;&lt;code&gt;ksops&lt;/code&gt;&amp;nbsp;and decrypt&amp;nbsp;&lt;code&gt;argocd-secret.sops.yml&lt;/code&gt;&amp;nbsp;during the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Reference the generator in Kustomize&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;In&amp;nbsp;&lt;code&gt;kustomization.yml&lt;/code&gt;, add the generator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize.config.k8s.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kustomization&lt;/span&gt;

&lt;span class="na"&gt;generators&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;secret-generator.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Argo CD renders this application, Kustomize runs the KSOPS generator and produces the decrypted&amp;nbsp;&lt;code&gt;argocd-secret&lt;/code&gt;&amp;nbsp;resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Configure Dex in Argo CD&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Now configure Argo CD to use GitLab through Dex.&lt;/p&gt;

&lt;p&gt;In Helm values, configure&amp;nbsp;&lt;code&gt;argocd-cm&lt;/code&gt;&amp;nbsp;like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kustomize.buildOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--enable-alpha-plugins&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--enable-exec"&lt;/span&gt;
    &lt;span class="na"&gt;dex.config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;connectors:&lt;/span&gt;
        &lt;span class="s"&gt;- type: gitlab&lt;/span&gt;
          &lt;span class="s"&gt;id: gitlab&lt;/span&gt;
          &lt;span class="s"&gt;name: GitLab&lt;/span&gt;
          &lt;span class="s"&gt;config:&lt;/span&gt;
            &lt;span class="s"&gt;baseURL: https://gitlab.example.com&lt;/span&gt;
            &lt;span class="s"&gt;clientID: $dex.gitlab.clientID&lt;/span&gt;
            &lt;span class="s"&gt;clientSecret: $dex.gitlab.clientSecret&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The&amp;nbsp;&lt;code&gt;$dex.gitlab.*&lt;/code&gt;&amp;nbsp;values are loaded from&amp;nbsp;&lt;code&gt;argocd-secret&lt;/code&gt;. That is why the secret names must match exactly.&lt;/p&gt;

&lt;p&gt;The Kustomize build options matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;kustomize.buildOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--enable-alpha-plugins&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;--enable-exec"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allows Kustomize to execute the KSOPS plugin.&lt;/p&gt;

&lt;p&gt;Do not enable exec plugins for repositories you do not trust. Otherwise, you have invented remote command execution with extra YAML. Security teams love that. Briefly.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Configure Argo CD RBAC&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Authentication is not authorization. After GitLab login works, configure Argo CD RBAC.&lt;/p&gt;

&lt;p&gt;For simple user mapping by email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;valuesObject&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rbac&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;policy.csv&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;g, alice@example.com, role:admin&lt;/span&gt;
          &lt;span class="s"&gt;g, bob@example.com, role:readonly&lt;/span&gt;
        &lt;span class="na"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[email,groups]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This maps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;alice@example.com -&amp;gt; role:admin&lt;/span&gt;
&lt;span class="s"&gt;bob@example.com -&amp;gt; role:readonly&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For larger teams, prefer GitLab groups instead of individual users. User by user RBAC starts cute and ends as spreadsheet archaeology.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Apply the configuration&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If Argo CD is managed by another Argo CD application, commit the files and let Argo CD sync them.&lt;/p&gt;

&lt;p&gt;If you are applying locally for a test, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kustomize build &lt;span class="nt"&gt;--enable-alpha-plugins&lt;/span&gt; &lt;span class="nt"&gt;--enable-exec&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; | kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart the Argo CD components if needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd rollout restart deployment argocd-server
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd rollout restart deployment argocd-dex-server
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd rollout restart deployment argocd-repo-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;Verify the setup&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Check that the secret exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd get secret argocd-secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the Dex logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd logs deploy/argocd-dex-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the Argo CD server logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd logs deploy/argocd-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the repo server if decryption fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd logs deploy/argocd-repo-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open Argo CD in the browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://argocd.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see a GitLab login option. After login, the user should land in Argo CD with the expected role.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Troubleshoot common failures&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If GitLab returns a redirect error, check the redirect URI.&lt;/p&gt;

&lt;p&gt;It must match exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://argocd.example.com/api/dex/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If login works but RBAC does not, check which claim Argo CD receives. The email in&amp;nbsp;&lt;code&gt;policy.csv&lt;/code&gt;&amp;nbsp;must match the user claim.&lt;/p&gt;

&lt;p&gt;If the secret does not render, check the repo server pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd describe pod &lt;span class="nt"&gt;-l&lt;/span&gt; app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;argocd-repo-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common causes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;KSOPS is not installed in the repo server.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-enable-alpha-plugins --enable-exec&lt;/code&gt;&amp;nbsp;is missing.&lt;/li&gt;
&lt;li&gt;The age private key is not available in the repo server environment.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SOPS_AGE_KEY&lt;/code&gt;&amp;nbsp;is missing or points to the wrong Kubernetes secret key.&lt;/li&gt;
&lt;li&gt;The encrypted file name does not match&amp;nbsp;&lt;code&gt;secret-generator.yml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The secret keys do not match&amp;nbsp;&lt;code&gt;$dex.gitlab.clientID&lt;/code&gt;,&amp;nbsp;&lt;code&gt;$dex.gitlab.clientSecret&lt;/code&gt;, or&amp;nbsp;&lt;code&gt;$dex.gitlab.redirectURI&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can test decryption locally if you have the age key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SOPS_AGE_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;age.key&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; sops &lt;span class="nt"&gt;--decrypt&lt;/span&gt; argocd-secret.sops.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  &lt;strong&gt;Security notes&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Keep the boring security parts boring. Boring is good. Boring means nobody is explaining an outage in a meeting with too many directors.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Encrypt the GitLab OAuth secret with SOPS.&lt;/li&gt;
&lt;li&gt;Protect the age private key.&lt;/li&gt;
&lt;li&gt;Limit who can change Argo CD applications that use Kustomize exec plugins.&lt;/li&gt;
&lt;li&gt;Use GitLab groups for larger teams.&lt;/li&gt;
&lt;li&gt;Rotate the GitLab OAuth secret periodically.&lt;/li&gt;
&lt;li&gt;Audit&amp;nbsp;&lt;code&gt;role:admin&lt;/code&gt;&amp;nbsp;assignments.&lt;/li&gt;
&lt;li&gt;Avoid giving admin access to users because they asked nicely in Slack.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Final result&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;With this setup, GitLab becomes the login provider for Argo CD, secrets stay encrypted in Git, and Argo CD RBAC controls what authenticated users can do.&lt;/p&gt;

&lt;p&gt;The moving parts are not complicated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitLab OAuth app
SOPS encrypted argocd-secret
KSOPS in repo server
Dex GitLab connector
Argo CD RBAC policy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a reasonable amount of YAML for centralized authentication. Not small, obviously. This is Kubernetes. We measure configuration in geological layers.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>gitlab</category>
      <category>argocd</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
