<?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: curioustore</title>
    <description>The latest articles on DEV Community by curioustore (@curioustore_48788631d0e2e).</description>
    <link>https://dev.to/curioustore_48788631d0e2e</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3951469%2F0063106a-bb19-47ec-a975-494af57b10c9.jpg</url>
      <title>DEV Community: curioustore</title>
      <link>https://dev.to/curioustore_48788631d0e2e</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/curioustore_48788631d0e2e"/>
    <language>en</language>
    <item>
      <title>Vercel AI SDK 6: An Agent Is Just a while Loop</title>
      <dc:creator>curioustore</dc:creator>
      <pubDate>Fri, 10 Jul 2026 14:38:28 +0000</pubDate>
      <link>https://dev.to/curioustore_48788631d0e2e/vercel-ai-sdk-6-an-agent-is-just-a-while-loop-4kf3</link>
      <guid>https://dev.to/curioustore_48788631d0e2e/vercel-ai-sdk-6-an-agent-is-just-a-while-loop-4kf3</guid>
      <description>&lt;p&gt;I wired the same model and the same single tool into two functions in the exact same way. One of them ran the tool once and then &lt;strong&gt;gave back no answer at all&lt;/strong&gt;; the other &lt;strong&gt;called that same model twenty times over.&lt;/strong&gt; The only thing that changed in the code was one line — whether or not I flipped on an option called &lt;code&gt;stopWhen&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That was the scene I ran into the first time I actually installed Vercel AI SDK 6 and ran something on top of &lt;code&gt;ai@6.0.212&lt;/code&gt;. These days the library leads with the banner of an "agent framework." Yet what that word "agent" points to at the level of real code turns out to be surprisingly fuzzy. So this post strips away the marketing copy and takes apart, by hand, what the AI SDK 6 agent actually is as a control flow — &lt;strong&gt;with no model API key, no GPU, and not a single external call.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!NOTE]&lt;br&gt;
Every number and behavior in this post was verified by running it myself against a &lt;strong&gt;mock model&lt;/strong&gt; on Windows 11 · Node v24.15.0 · &lt;code&gt;ai@6.0.212&lt;/code&gt; · &lt;code&gt;zod@4.4.3&lt;/code&gt;. Because no real LLM is ever called, the results reproduce identically every time. Why the "mock model" is the crux is something I explain separately halfway through.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What does the AI SDK actually do for you
&lt;/h2&gt;

&lt;p&gt;Let me lay the groundwork before the main event, so that even a first-time reader can follow all the way through.&lt;/p&gt;

&lt;p&gt;In web development, when we send an HTTP request to a server, we don't hand-write the low-level, browser-specific code ourselves. An &lt;strong&gt;abstraction layer&lt;/strong&gt; like &lt;code&gt;fetch&lt;/code&gt; or &lt;code&gt;axios&lt;/code&gt; papers over the differences. &lt;strong&gt;The AI SDK is &lt;code&gt;fetch&lt;/code&gt; for calling LLMs (large language models).&lt;/strong&gt; It wraps the vendor-by-vendor APIs — OpenAI, Anthropic, Google — behind a single function like &lt;code&gt;generateText&lt;/code&gt;, so you can swap models like changing a plug in a socket while your app code stays put. Think of it as an adapter that turns every vendor's differently shaped plug into one power strip.&lt;/p&gt;

&lt;p&gt;As of 2026, AI SDK 6 has moved up to a &lt;a href="https://vercel.com/blog/ai-sdk-6" rel="noopener noreferrer"&gt;general availability (GA) release&lt;/a&gt; (not a beta), and the &lt;code&gt;ai@6.0.x&lt;/code&gt; package is still being updated as of late June. Four things stand out as the headline changes from 5 to 6.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ToolLoopAgent&lt;/code&gt;&lt;/strong&gt; — the official API that bundles "the agent" into a single class (the protagonist of this post).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human-in-the-loop tool approval&lt;/strong&gt; — a mechanism that inserts a human check before a risky tool call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stabilized structured output&lt;/strong&gt; — forcing a model's response into an object of a fixed shape rather than free text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DevTools&lt;/strong&gt; — a debugging tool for inspecting which steps an agent took.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let me unpack just two terms first. A &lt;strong&gt;tool call&lt;/strong&gt; is the model asking, "instead of answering directly, please call the &lt;code&gt;getWeather('Seoul')&lt;/code&gt; function for me." The model can't run functions itself, so our code does the running and hands the result back to the model. An &lt;strong&gt;agent&lt;/strong&gt; is exactly this round trip — model calls a tool → we run it and return the result → the model looks at that and decides what's next — repeated until the job is done. It sounds grand, but as we'll see shortly, the reality of it is one ordinary loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an "agent" really is: the while loop that stopWhen turns on
&lt;/h2&gt;

&lt;p&gt;The first thing I wanted to confirm was this: if you hand tools to &lt;code&gt;generateText&lt;/code&gt;, does that by itself make it an agent?&lt;/p&gt;

&lt;p&gt;I scripted the mock model to move along like this. On &lt;strong&gt;step 1&lt;/strong&gt; it returns a tool call saying "call the &lt;code&gt;weather&lt;/code&gt; tool with &lt;code&gt;{city:'Seoul'}&lt;/code&gt;," and on &lt;strong&gt;step 2&lt;/strong&gt; it returns the final text "Seoul is 21C." Then I called that same model two different ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, plainly, with no options&lt;/strong&gt; — &lt;code&gt;generateText({ model, tools, prompt })&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observation&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Step count&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model calls (&lt;code&gt;doGenerate&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final text&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;"" (empty string)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Did the tool run?&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes&lt;/strong&gt; — &lt;code&gt;{city:'Seoul', tempC:21}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The tool clearly ran. And yet the final answer is empty. Why? Because the model called the tool once and &lt;strong&gt;stopped&lt;/strong&gt; right there. The tool's result &lt;strong&gt;never went back&lt;/strong&gt; to the model, and the model never got a second turn. By default, &lt;code&gt;generateText&lt;/code&gt; is a &lt;strong&gt;one-shot function&lt;/strong&gt; that ends the moment a tool is called once. The automatic round trip we casually call "an agent" simply &lt;strong&gt;isn't here.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Now I added a single line&lt;/strong&gt; — &lt;code&gt;stopWhen: stepCountIs(5)&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Observation&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Step count&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model calls&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final text&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;"Seoul is 21C."&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same model, same tool — but this time the model was called twice. When the first call requested the tool, the SDK ran it, appended the result to the conversation, and &lt;strong&gt;called the model again&lt;/strong&gt;; on that second call the model finally produced its closing sentence. That is exactly the contradictory scene from the opening.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stopWhen&lt;/code&gt;, true to its name, is the condition that decides &lt;strong&gt;when to stop.&lt;/strong&gt; &lt;code&gt;stepCountIs(5)&lt;/code&gt; means "run for at most 5 steps," &lt;code&gt;hasToolCall('weather')&lt;/code&gt; means "stop the moment that tool is called," and you can supply your own function for a custom condition too. Sure enough, when I set &lt;code&gt;hasToolCall('weather')&lt;/code&gt;, it stopped at exactly step 1, right after the tool was called.&lt;/p&gt;

&lt;p&gt;So in AI SDK 6, &lt;strong&gt;what an "agent" really is is a &lt;code&gt;while&lt;/code&gt; loop that keeps calling the model until &lt;code&gt;stopWhen&lt;/code&gt; says stop.&lt;/strong&gt; Not magic — a loop with a termination condition, and the switch that turns that termination condition on is &lt;code&gt;stopWhen&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Diagram:&lt;/strong&gt; this section is illustrated with a flowchart — &lt;a href="https://var.gg/en/blog/vercel-ai-sdk-6-agent-loop" rel="noopener noreferrer"&gt;view it in the original article on var.gg&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is where a class called &lt;a href="https://ai-sdk.dev/docs/reference/ai-sdk-core/tool-loop-agent" rel="noopener noreferrer"&gt;&lt;code&gt;ToolLoopAgent&lt;/code&gt;&lt;/a&gt; comes in. Its official name is &lt;code&gt;ToolLoopAgent&lt;/code&gt; (the older beta's &lt;code&gt;Experimental_Agent&lt;/code&gt; remains as an alias pointing at the same class — in my install, &lt;code&gt;ToolLoopAgent === Experimental_Agent&lt;/code&gt; was &lt;code&gt;true&lt;/code&gt;), and what it does is bundle the model, tools, and &lt;code&gt;stopWhen&lt;/code&gt; into one object: a &lt;strong&gt;thin wrapper.&lt;/strong&gt; Its internal loop is identical to the one we just saw in &lt;code&gt;generateText&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is one decisive difference, though. &lt;strong&gt;Even if you don't give &lt;code&gt;ToolLoopAgent&lt;/code&gt; a &lt;code&gt;stopWhen&lt;/code&gt;, it ships with &lt;code&gt;stepCountIs(20)&lt;/code&gt; as the default.&lt;/strong&gt; I fed a mock model that endlessly re-requests the tool into both approaches.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call style&lt;/th&gt;
&lt;th&gt;Step count with no stopWhen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;generateText&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1&lt;/strong&gt; (no loop)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ToolLoopAgent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;20&lt;/strong&gt; (stops at the default cap)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same model, but one runs once and the other twenty times. That number 20 is the whole identity of "the agent." &lt;code&gt;generateText&lt;/code&gt; starts out with its loop &lt;strong&gt;switched off&lt;/strong&gt;, and &lt;code&gt;ToolLoopAgent&lt;/code&gt; starts out with its loop &lt;strong&gt;switched on at 20 steps.&lt;/strong&gt; That is the entirety of the essential difference between the two APIs.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!WARNING]&lt;br&gt;
So here's the first real-world trap. If the model, for whatever reason, never stops calling tools, &lt;code&gt;ToolLoopAgent&lt;/code&gt; will &lt;strong&gt;quietly call the model 20 times — meaning you're billed 20 times.&lt;/strong&gt; It saves you from an infinite loop, but that ceiling is also your cost ceiling. &lt;code&gt;stopWhen&lt;/code&gt; is both a safety catch and a budget dial.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  When the loop hits an error: it doesn't die, it snitches to the model
&lt;/h2&gt;

&lt;p&gt;Tools don't always succeed cleanly. So I broke one on purpose. I built a tool whose &lt;code&gt;execute()&lt;/code&gt; immediately does &lt;code&gt;throw new Error('TOOL_BOOM')&lt;/code&gt; and dropped it into the loop. My expectation was "the loop blows up on the exception and dies."&lt;/p&gt;

&lt;p&gt;Wrong. The loop &lt;strong&gt;did not throw.&lt;/strong&gt; Instead it went like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That step's content was recorded as two pieces, &lt;code&gt;["tool-call", "tool-error"]&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;that &lt;code&gt;tool-error&lt;/code&gt; was &lt;strong&gt;fed back to the model&lt;/strong&gt; in place of a tool result, and&lt;/li&gt;
&lt;li&gt;on the next step the model saw the error and tried to recover with the text "recovered."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same held when the model called a tool that doesn't even exist. Instead of a crash, it surfaced as a &lt;code&gt;tool-error&lt;/code&gt; and the loop carried on. In other words, &lt;strong&gt;AI SDK 6 is designed to catch tool-execution failures by default and, rather than throwing an exception, tell the model "this tool failed in this way."&lt;/strong&gt; It's a self-heal structure that leaves the model room to route around the failure or pick a different tool on its own.&lt;/p&gt;

&lt;p&gt;This isn't all upside. Because the error is quietly absorbed into the conversation, those of us outside the code may only notice later — "the tool failed, so why did I just pay more?" This automatic feedback is the &lt;strong&gt;default behavior&lt;/strong&gt;, and using it while knowing you can change it (via tool options or an error handler) is different from using it unaware. It rhymes with cooperative cancellation in general: when you abort a request midway, what actually stops and what keeps spinning on as "orphan work" always comes down to someone's cooperation — control is never something you hold unilaterally.&lt;/p&gt;

&lt;h2&gt;
  
  
  The type boundary of structured output: compile time and runtime are different layers
&lt;/h2&gt;

&lt;p&gt;The next thing I looked at was &lt;strong&gt;structured output.&lt;/strong&gt; A model, left to itself, spits out free text. But an app often needs an &lt;strong&gt;object of a fixed shape&lt;/strong&gt; like &lt;code&gt;{ city: string, tempC: number }&lt;/code&gt;. For this, the AI SDK provides — separate from the free-text &lt;code&gt;generateText&lt;/code&gt; — a path that forces the result to conform to a schema. The dedicated function &lt;code&gt;generateObject&lt;/code&gt; shows the boundary most sharply, so I experimented with it, but one thing is worth flagging up front: &lt;strong&gt;in v6, &lt;code&gt;generateObject&lt;/code&gt; is already marked &lt;code&gt;@deprecated&lt;/code&gt;.&lt;/strong&gt; The installed type definitions themselves steer you to "use the &lt;code&gt;output&lt;/code&gt; setting on &lt;code&gt;generateText&lt;/code&gt; instead." For new code, &lt;code&gt;generateText({ output: Output.object({ schema }) })&lt;/code&gt; is the recommended path (though this result still comes out under the name &lt;code&gt;experimental_output&lt;/code&gt; — i.e., the surface isn't fully set yet). Either way, &lt;strong&gt;the type-versus-validation boundary is the same&lt;/strong&gt;, so I'll show the experiment below with &lt;code&gt;generateObject&lt;/code&gt;, where that boundary is crispest.&lt;/p&gt;

&lt;p&gt;I gave it the &lt;code&gt;zod&lt;/code&gt; (a runtime schema-validation library) schema &lt;code&gt;z.object({ city: z.string(), tempC: z.number() })&lt;/code&gt; and ran three cases.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the model emitted&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;generateObject&lt;/code&gt; result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{"city":"Seoul","tempC":21}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Valid — typed object. At runtime &lt;code&gt;object.tempC * 2 = 42&lt;/code&gt; works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;{"city":"Seoul","tempC":"hot"}&lt;/code&gt; (type violation)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;AI_NoObjectGeneratedError&lt;/code&gt; thrown&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;totally not json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;AI_NoObjectGeneratedError&lt;/code&gt; thrown&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Let me clear up a common misconception here. "Give it a TypeScript type and the model will honor that type" is a &lt;strong&gt;half-truth.&lt;/strong&gt; The type is a &lt;strong&gt;compile-time promise&lt;/strong&gt;, and whether the model actually kept that promise is &lt;strong&gt;checked by zod at runtime.&lt;/strong&gt; They're different layers. The compiler guarantees the type, but the model can break the promise at runtime however it likes, and when it does the SDK &lt;strong&gt;throws an exception&lt;/strong&gt; rather than quietly forcing a fit.&lt;/p&gt;

&lt;p&gt;The type layer itself is solid. Checking with a separate type pass (&lt;code&gt;tsc --strict&lt;/code&gt;), &lt;code&gt;generateText&lt;/code&gt;'s result &lt;code&gt;.text&lt;/code&gt; is &lt;code&gt;string&lt;/code&gt; (no structure at all), while &lt;code&gt;generateObject({ schema })&lt;/code&gt;'s &lt;code&gt;.object.tempC&lt;/code&gt; was a &lt;code&gt;number&lt;/code&gt; inferred from the schema. Trying to assign a &lt;code&gt;string&lt;/code&gt; where a &lt;code&gt;number&lt;/code&gt; belongs produced a compile error. &lt;strong&gt;What the compiler catches is "does my code use the object correctly," and what zod catches is "did the model produce a correct object."&lt;/strong&gt; Mistake the two for the same thing and you'll get the experience where the types pass but production blows up with a &lt;code&gt;NoObjectGeneratedError&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming is a procession of typed parts
&lt;/h2&gt;

&lt;p&gt;When you stream a response with &lt;code&gt;streamText&lt;/code&gt;, characters trickle onto the screen — but what's happening underneath? With a mock model I built a stream mixing text fragments and tool calls, and transcribed the order of the pieces that land on &lt;code&gt;fullStream&lt;/code&gt; exactly as they came.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;start → start-step → text-start → text-delta → text-end
  → tool-input-start → tool-input-delta → tool-input-end
  → tool-call → tool-result → finish-step → finish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things matter here. First, a stream is not a blob of characters but a &lt;strong&gt;sequence of typed events&lt;/strong&gt; — text-start, text-delta, text-end; tool-input start, delta, end — each stage split into its own part, so the UI can render exactly whether "the model is calling a tool right now" or "is writing text." Second, &lt;code&gt;tool-result&lt;/code&gt; shows up &lt;strong&gt;in the middle of the stream&lt;/strong&gt;, right after &lt;code&gt;tool-call&lt;/code&gt;. That means the SDK doesn't wait for streaming to finish before running the tool; it runs the tool mid-flow and splices the result back into the stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quiet v5 → v6 trap: the vanished maxSteps
&lt;/h2&gt;

&lt;p&gt;One note for anyone considering a migration. In AI SDK 5, the option that turned on the multi-step loop was &lt;code&gt;maxSteps&lt;/code&gt;. &lt;strong&gt;In v6 this option was &lt;a href="https://ai-sdk.dev/docs/migration-guides/migration-guide-6-0" rel="noopener noreferrer"&gt;removed&lt;/a&gt;.&lt;/strong&gt; The problem is that it disappears without making any noise.&lt;/p&gt;

&lt;p&gt;I passed &lt;code&gt;generateText({ ..., maxSteps: 5 })&lt;/code&gt; through just like v5 code. Type checking did flag that &lt;code&gt;maxSteps&lt;/code&gt; is no longer a valid option (confirmed with &lt;code&gt;@ts-expect-error&lt;/code&gt;), but &lt;strong&gt;at runtime it was silently ignored&lt;/strong&gt; — step count 1, one model call, final text an empty string. In other words, an agent loop that ran fine on &lt;code&gt;maxSteps&lt;/code&gt; in v5 will, in any environment that skips type checking (pure JS, a loose build), &lt;strong&gt;degrade into a one-shot function without a single line of error&lt;/strong&gt; the moment you bump to v6, and the answer goes empty. In v6 you have to turn the loop on with &lt;code&gt;stopWhen&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!NOTE]&lt;br&gt;
To sum up, there are two ways to turn on multi-step in v6 — the functional way gives &lt;code&gt;stopWhen&lt;/code&gt; directly to &lt;code&gt;generateText&lt;/code&gt;/&lt;code&gt;streamText&lt;/code&gt;, and the object way uses &lt;code&gt;ToolLoopAgent&lt;/code&gt; and decides whether to keep or lower the default &lt;code&gt;stepCountIs(20)&lt;/code&gt;. The v5 &lt;code&gt;maxSteps&lt;/code&gt; and &lt;code&gt;system&lt;/code&gt; parameters were renamed to &lt;code&gt;stopWhen&lt;/code&gt; and &lt;code&gt;instructions&lt;/code&gt;, respectively.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What it gives you, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;After running it myself, the most honest thing I can say is that AI SDK 6's &lt;strong&gt;boundary between what it gives you and what it doesn't is sharper than you'd expect.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it gives&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provider abstraction — swap model vendors and your app code stays almost the same.&lt;/li&gt;
&lt;li&gt;A type-safe surface — tool inputs/outputs and structured output are bound on both sides, by TypeScript types and zod runtime validation.&lt;/li&gt;
&lt;li&gt;A standardized stream — typed parts let the UI draw each stage precisely.&lt;/li&gt;
&lt;li&gt;Testability — with the &lt;code&gt;mock model&lt;/code&gt; I'll cover separately below, you can reproduce the entire control flow deterministically, no model required.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What it doesn't give&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The agent's &lt;em&gt;reliability.&lt;/em&gt; It provides the loop, but there's no guarantee the model calls the right tool the right way. Preventing an infinite loop is on you, via &lt;code&gt;stopWhen&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Rollback&lt;/em&gt; of side effects. If a tool wrote to the outside world, halting the loop doesn't undo it. Cancellation is not rollback.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Complete&lt;/em&gt; concealment of provider differences. Native support for structured output and the streaming details vary by real model, and a mock model won't show you those differences.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This last item matters. Every conclusion in this post is &lt;strong&gt;the "contract and control flow" seen on top of a mock model&lt;/strong&gt; — it does not reproduce a real LLM's caprice (nonsensical tool arguments, an unquenchable appetite for calling tools). The mock model clearly shows one half of the truth — how the SDK &lt;em&gt;promised&lt;/em&gt; to behave — and the other half (how the model breaks that promise) only surfaces with a real provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  But why is the "mock model" the crux?
&lt;/h3&gt;

&lt;p&gt;A &lt;code&gt;mock model&lt;/code&gt; is a model implementation whose script we write in ourselves, in place of a real LLM. AI SDK 6 officially ships &lt;code&gt;MockLanguageModelV3&lt;/code&gt; in &lt;code&gt;ai/test&lt;/code&gt;. Because you can nail down in code "return this tool call as the response, then return this text next," you can reproduce the agent loop, the stop condition, error handling, and streaming order in full — &lt;strong&gt;with no API key, no cost, and no network.&lt;/strong&gt; This is exactly why this post could fill in every table above while calling the model zero times.&lt;/p&gt;

&lt;p&gt;Plenty of "build a chatbot with the AI SDK" posts are out there, but ones that tackle &lt;strong&gt;"how to test AI features deterministically, at zero cost"&lt;/strong&gt; head-on are rare. From a product-code standpoint the latter is actually the more urgent need, because you can't validate the loop and failure paths of an agent bound for production against a real model that burns money and wobbles on every run. Set this next to how the Python world pins an agent's inputs and outputs down with types to solve the same problem, and the contrast gets sharp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measured against the alternatives
&lt;/h2&gt;

&lt;p&gt;The AI SDK isn't the only road to building an agent. Here's how it shakes out, based on hands-on experience and each tool's official positioning.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Abstraction level&lt;/th&gt;
&lt;th&gt;Type safety&lt;/th&gt;
&lt;th&gt;Testability&lt;/th&gt;
&lt;th&gt;Lock-in risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI SDK 6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Medium — provider abstraction + loop&lt;/td&gt;
&lt;td&gt;High (TS + zod)&lt;/td&gt;
&lt;td&gt;High (official mock model)&lt;/td&gt;
&lt;td&gt;Low (multi-provider)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LangChain.js&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High — up to chains/graphs&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium (heavy ecosystem dependence)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mastra&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High — workflow-centric&lt;/td&gt;
&lt;td&gt;High (TS-first)&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenAI Agents SDK (JS)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Medium — agent-specialized&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;High (OpenAI-flavored)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Raw fetch&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;By hand&lt;/td&gt;
&lt;td&gt;By hand&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The point isn't "which is best." AI SDK 6's place is the spot that &lt;strong&gt;keeps the abstraction thin (only as far as the loop and the provider) while keeping types and testing solid.&lt;/strong&gt; If you need higher-level orchestration (complex branching, long-running workflows), LangChain or Mastra may fit better; if you're fine binding deeply to one provider, that vendor's SDK might. The AI SDK's strength isn't a flashy graph — it's that, just as we did a moment ago, you can &lt;strong&gt;inspect every corner of the loop without a model.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When to reach for it, and what to remember
&lt;/h2&gt;

&lt;p&gt;This tool shines brightest when you want to add "an AI feature with tool calls, in a way that's less bound to a provider and solid on types and testing." Conversely, if complex long-running workflow orchestration is the essence of what you're building, look to a heavier framework. After taking it apart myself, the sentences that stick are just a few.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An agent isn't magic; it's a &lt;code&gt;while&lt;/code&gt; loop that &lt;code&gt;stopWhen&lt;/code&gt; turns on.&lt;/strong&gt; &lt;code&gt;generateText&lt;/code&gt; starts with the loop off, &lt;code&gt;ToolLoopAgent&lt;/code&gt; with it on at a default of 20 steps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every step of the loop is a cost.&lt;/strong&gt; &lt;code&gt;stopWhen&lt;/code&gt; is both a safety catch and a budget dial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool failures don't blow up by default; they're fed back to the model.&lt;/strong&gt; Self-healing is convenient, but it can become a silent cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Types (compile time) and validation (runtime) are different layers.&lt;/strong&gt; &lt;code&gt;generateObject&lt;/code&gt; answers a promise-breaking model with an exception.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;maxSteps&lt;/code&gt; is gone in v6.&lt;/strong&gt; It can quietly degrade into a one-shot function during migration, so move it over to &lt;code&gt;stopWhen&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In an era where "agents write the code," using an agent without knowing what loop it actually is on the inside is precarious. (I reached the same conclusion writing about &lt;a href="https://var.gg/en/blog/git-tools-in-the-agent-era" rel="noopener noreferrer"&gt;git tooling in the agent era&lt;/a&gt; — the smarter an abstraction gets, the more you owe it to yourself to run the thing underneath at least once and see what actually turns.) The AI SDK 6 "agent," fortunately, was open enough to inspect all the way through without calling a model even once.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://var.gg/en/blog/vercel-ai-sdk-6-agent-loop" rel="noopener noreferrer"&gt;var.gg&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>uv audit vs pip-audit, and a gate narrower than it looks</title>
      <dc:creator>curioustore</dc:creator>
      <pubDate>Fri, 10 Jul 2026 14:38:23 +0000</pubDate>
      <link>https://dev.to/curioustore_48788631d0e2e/uv-audit-vs-pip-audit-and-a-gate-narrower-than-it-looks-30nf</link>
      <guid>https://dev.to/curioustore_48788631d0e2e/uv-audit-vs-pip-audit-and-a-gate-narrower-than-it-looks-30nf</guid>
      <description>&lt;p&gt;Until now, the tools that install packages have never once stopped to ask, "Are you sure it's okay to take this one?" Whether it's &lt;code&gt;pip install&lt;/code&gt; or &lt;code&gt;npm install&lt;/code&gt;, they've been faithful delivery drivers: they read the name and version on the invoice, fetch exactly that item, and set it on your doorstep. Whether the item was recalled days ago, or is a counterfeit someone swapped in under the same name, was never the driver's concern.&lt;/p&gt;

&lt;p&gt;This faithfulness is usually a virtue, but it carries one uncomfortable trait. A &lt;strong&gt;lockfile&lt;/strong&gt; — a file that pins the exact versions and dependency graph of what you'll install, &lt;code&gt;uv.lock&lt;/code&gt; being the canonical example — exists for the sake of reproducibility. The version you picked six months ago installs today, and on a colleague's machine too, identically, down to the last bit. But what if that choice six months ago was a &lt;em&gt;bad&lt;/em&gt; one? A lockfile faithfully reproduces even a dangerous past decision. Even for a package that was pulled from PyPI after a problem surfaced, if your lockfile already knows where that file lives, the install path quietly stays open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;uv&lt;/strong&gt;, the Python package manager built by &lt;a href="https://astral.sh/blog/uv-audit" rel="noopener noreferrer"&gt;Astral&lt;/a&gt;, started in 2026 to hand this faithful delivery driver a new role for the first time: the role of an inspector who pauses just before installing to say, "Hold on — this item is on a list." On June 8, Astral unveiled &lt;code&gt;uv audit&lt;/code&gt; (a dependency vulnerability scanner) and a malicious-package check that runs at install time. Both are still preview (experimental, subject to change at any time).&lt;/p&gt;

&lt;p&gt;I didn't just read about this. I pulled the latest uv, built a throwaway project deliberately stuffed with vulnerable packages, and ran &lt;code&gt;uv audit&lt;/code&gt; side by side with the long-standing standard tool &lt;code&gt;pip-audit&lt;/code&gt; against the same lockfile, measuring speed and results myself. I also cracked open the binary to see where the malicious-package gate actually fires. Along the way I hit a few places where the official blog's wording didn't quite match what I saw, and a security boundary far narrower than I'd expected. Let me walk through it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The uv version used in this article is &lt;strong&gt;0.11.23&lt;/strong&gt; (released 2026-06-19). Every measurement was done on a single Windows machine, in an isolated throwaway project that never touched any production environment.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  First, let's separate two features that look alike
&lt;/h2&gt;

&lt;p&gt;When you first see the announcement, it's easy to read it as a vague "uv added a security feature." But what actually landed is &lt;strong&gt;two things of a different nature&lt;/strong&gt;, and conflating them makes the whole story confusing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;uv audit&lt;/code&gt;&lt;/strong&gt; — a &lt;strong&gt;scanner&lt;/strong&gt; that checks the package names and versions in your lockfile against records of already-known vulnerabilities. It doesn't block anything. It simply reports "there are N known problems among these" and stops there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;UV_MALWARE_CHECK&lt;/code&gt;&lt;/strong&gt; — a gate that, just before &lt;code&gt;uv sync&lt;/code&gt; performs the actual install, &lt;strong&gt;aborts the install itself&lt;/strong&gt; if a known malicious package version is in the mix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The latter is the one that comes close to a real "security gate." But both share a common limitation. &lt;strong&gt;Despite the name, neither analyzes the contents of a package file the way antivirus would.&lt;/strong&gt; What both actually do is compare your packages against a "list of bad names and versions" already registered in a public database called OSV.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One-line glossary — &lt;strong&gt;OSV (Open Source Vulnerabilities)&lt;/strong&gt;: a public database and API that gathers, in a single common format, which version of which package corresponds to which known vulnerability or malicious behavior. It pulls in many sources — Python security advisories, OpenSSF's malicious-package reports, and more. Malicious-package records usually carry an identifier starting with &lt;code&gt;MAL-&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's an analogy. This check is not the airport's &lt;strong&gt;baggage X-ray&lt;/strong&gt;. It doesn't open your bag and look inside; it's closer to matching passenger names against a &lt;strong&gt;no-fly list&lt;/strong&gt; at the gate. Anyone on the list gets stopped. But a new threat not yet on the list, a forged ID no one has seen before, simply walks through. Keep this analogy in mind and the "things this does &lt;em&gt;not&lt;/em&gt; block," coming later, will make natural sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running &lt;code&gt;uv audit&lt;/code&gt; myself
&lt;/h2&gt;

&lt;p&gt;Running it once is faster than describing it in words. I built a throwaway project deliberately filled with old, vulnerable versions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[project]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"audit-fixture"&lt;/span&gt;
&lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.1.0"&lt;/span&gt;
&lt;span class="py"&gt;requires-python&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="py"&gt;"&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;3.11&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="py"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="py"&gt;"requests=&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.19&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;    &lt;span class="py"&gt;"jinja2=&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.10&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;    &lt;span class="py"&gt;"pyyaml=&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;5.3&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;    &lt;span class="py"&gt;"flask=&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.12&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="s"&gt;",&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;uv lock&lt;/code&gt; pinned it to 14 packages counting direct and indirect dependencies (including &lt;strong&gt;transitive dependencies&lt;/strong&gt; — the ones I didn't list myself but another package dragged in, like &lt;code&gt;urllib3&lt;/code&gt; pulled in by &lt;code&gt;requests&lt;/code&gt; or &lt;code&gt;werkzeug&lt;/code&gt; pulled in by &lt;code&gt;flask&lt;/code&gt;). Then I ran the audit on top of that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ uv audit
warning: `uv audit` is experimental and may change without warning.
         Pass `--preview-features audit-command` to disable this warning.
Found 48 known vulnerabilities and no adverse project statuses in 13 packages
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's honest from the very first line. Because &lt;code&gt;uv audit&lt;/code&gt; is &lt;strong&gt;preview&lt;/strong&gt;, running it plain raises a warning that it's an experimental feature and may change at any time. Adding &lt;code&gt;--preview-features audit-command&lt;/code&gt; only makes the warning go away; the feature behaves exactly the same. In other words, this flag isn't a "switch that turns the feature on" but a stamp that confirms "I know this is experimental."&lt;/p&gt;

&lt;p&gt;The result: 48 known vulnerabilities across 13 packages. The human-readable &lt;code&gt;text&lt;/code&gt; output breaks the vulnerabilities out package by package.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flask 0.12.2 has 7 known vulnerabilities:

- GHSA-562c-5r94-xh97: Flask is vulnerable to Denial of Service ...
  Fixed in: 0.12.3
  Advisory information: https://nvd.nist.gov/vuln/detail/CVE-2018-1000656
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;One-line glossary — &lt;strong&gt;CVE / GHSA&lt;/strong&gt;: both are identifiers attached to a vulnerability. A CVE is a number many organizations use in common; a GHSA is a number GitHub assigns. The same incident can hold both numbers at once, and that fact becomes important later when comparing tools.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Not for humans — machine-readable output
&lt;/h3&gt;

&lt;p&gt;Up to here, it's just a prettier &lt;code&gt;pip-audit&lt;/code&gt;. The place &lt;code&gt;uv audit&lt;/code&gt; is really aiming for is the &lt;strong&gt;CI (continuous integration) pipeline&lt;/strong&gt;. That's why there are three output formats.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;What I verified&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;reading in a terminal&lt;/td&gt;
&lt;td&gt;the default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;post-processing / policy scripts&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;schema&lt;/code&gt; (version=preview), &lt;code&gt;summary&lt;/code&gt;, &lt;code&gt;vulnerabilities[]&lt;/code&gt;, &lt;code&gt;adverse_statuses&lt;/code&gt; keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sarif&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;upload to GitHub Code Scanning&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;SARIF 2.1.0&lt;/strong&gt;, tool name &lt;code&gt;uv&lt;/code&gt; 0.11.23, 48 rules and 48 results&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;One-line glossary — &lt;strong&gt;SARIF&lt;/strong&gt;: a JSON specification for uploading static-analysis results to systems like GitHub in a standard format. With it, audit results show up automatically in a PR's "Security" tab. SARIF output was only added on June 18 in uv 0.11.22 — a version from a month earlier doesn't have it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The exit code is CI-friendly too. It returns &lt;strong&gt;1&lt;/strong&gt; if there's even one vulnerability and &lt;strong&gt;0&lt;/strong&gt; if things are clean. So a single line of &lt;code&gt;uv audit&lt;/code&gt;, with no extra script, can turn CI red. When I switched to a clean project with &lt;code&gt;certifi&lt;/code&gt; as the only dependency, it returned exit code 0 as expected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Silencing the noise, and the trap in it
&lt;/h3&gt;

&lt;p&gt;Not every known vulnerability is "a risk to us right now." So &lt;code&gt;--ignore&lt;/code&gt; lets you hide a specific advisory. What was interesting was the matching behavior. Passing a single &lt;code&gt;--ignore GHSA-562c-5r94-xh97&lt;/code&gt; dropped the count from 48 to &lt;strong&gt;46&lt;/strong&gt; — two entries disappeared. That's because the alias records (CVE, PYSEC) tied to that GHSA were suppressed along with it. It means an ID is matched inclusive of its aliases.&lt;/p&gt;

&lt;p&gt;There's an operational trap here. &lt;code&gt;--ignore-until-fixed&lt;/code&gt; sounds reasonable — "hide it only until a fixed version ships" — but &lt;strong&gt;if a fixed version never ships, it stays hidden forever.&lt;/strong&gt; It's not an expiry like "grant a pass for 30 days." So in practice it's safer to manage the ignore list separately, outside uv's configuration — who granted the pass, why, until when, and under which ticket.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# minimal form of an ignore policy that should live outside uv&lt;/span&gt;
&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GHSA-xxxx-yyyy-zzzz&lt;/span&gt;
&lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;platform-security&lt;/span&gt;
&lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;vulnerable code path is never actually invoked&lt;/span&gt;
&lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-07-20&lt;/span&gt;
&lt;span class="na"&gt;ticket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SEC-1234&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Side by side with &lt;code&gt;pip-audit&lt;/code&gt;: same answer, different speed
&lt;/h2&gt;

&lt;p&gt;Now the real question. &lt;strong&gt;How is this different from &lt;code&gt;pip-audit&lt;/code&gt;, the long-standing standard?&lt;/strong&gt; I exported the same lockfile to &lt;code&gt;requirements.txt&lt;/code&gt;, then ran both tools aligned to the same OSV data source.&lt;/p&gt;

&lt;p&gt;Results first. I took the vulnerabilities each tool found, expanded them into sets down to their aliases, and compared.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uv audit     : unique IDs (aliases included) 79
pip-audit    : unique IDs (aliases included) 79
intersection : 79  (symmetric difference 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;They were completely identical.&lt;/strong&gt; Even the distribution by type matched (PYSEC 18, CVE 30, GHSA 30, SNYK 1). The set of vulnerable packages was the same too. Which is also the expected result — both look at the same OSV. That's the first key point.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!NOTE]&lt;br&gt;
The value of &lt;code&gt;uv audit&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; that it "finds more vulnerabilities." If it looks at the same data, it finds the same things. The real difference is (1) speed, (2) native SARIF/JSON output, and (3) that it's integrated with the lockfile and resolver inside a single tool.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So what about speed? Here are the results, measured with the tools' caches warmed up.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;cold (first run)&lt;/th&gt;
&lt;th&gt;warm (median)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uv audit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~2.2s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~0.8s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pip-audit&lt;/code&gt; (OSV)&lt;/td&gt;
&lt;td&gt;22.7s*&lt;/td&gt;
&lt;td&gt;~16s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* &lt;code&gt;pip-audit&lt;/code&gt;'s cold run includes the time to install the tool itself.&lt;/p&gt;

&lt;p&gt;It felt like more than a 20x difference. But &lt;strong&gt;I have to be honest here.&lt;/strong&gt; This is not a well-controlled benchmark. I let my &lt;code&gt;pip-audit&lt;/code&gt; runs re-resolve dependencies every time (skipping optimizations like &lt;code&gt;--no-deps&lt;/code&gt;), while I let uv use the lockfile as-is. The number Astral officially cites is "&lt;strong&gt;4–10x&lt;/strong&gt;," and in the same blog post they add their own caveat that the gap can shrink substantially once &lt;code&gt;pip-audit&lt;/code&gt;'s cache and resolution are sufficiently warm. My measurement can only say "uv is clearly faster"; the "exactly how many times" is a number my measurement conditions produced.&lt;/p&gt;

&lt;p&gt;That's a lesson in itself. A benchmark can be inflated in any direction if you're inclined to. To trust a comparison you have to align the same data source, the same input scope, the same cache state — an "Nx faster" that doesn't is just a headline. (When I &lt;a href="https://var.gg/en/blog/typescript-native-tsgo" rel="noopener noreferrer"&gt;benchmarked the TypeScript 7 native compiler myself&lt;/a&gt; on this blog, the official "10x" turned out to be 3x on our code. A headline multiplier is always a number that came out of someone's particular codebase.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The side that blocks installs: the malicious-package gate
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;uv audit&lt;/code&gt; only reports. Actually &lt;em&gt;blocking&lt;/em&gt; an install is a separate feature. And here I ran into a point that diverges from the official guidance.&lt;/p&gt;

&lt;p&gt;First, this gate attaches to &lt;strong&gt;&lt;code&gt;uv sync&lt;/code&gt; (the install path)&lt;/strong&gt;, not to &lt;code&gt;uv audit&lt;/code&gt;. To confirm how it's enabled, I dug through the strings in the uv 0.11.23 binary directly, and this came out.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Malware detected in one or more dependencies that would be installed;
aborting sync. Set `UV_MALWARE_CHECK=0` to bypass this check.

Malware checks are experimental and may change without warning.
Pass `--preview-features malware-check` to disable this warning.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two messages say a lot. Setting &lt;code&gt;UV_MALWARE_CHECK=1&lt;/code&gt; on an empty venv and running &lt;code&gt;uv sync&lt;/code&gt; did raise the experimental-feature warning above, and the check ran. In other words, &lt;strong&gt;the activation switch is the environment variable &lt;code&gt;UV_MALWARE_CHECK=1&lt;/code&gt;, and the &lt;code&gt;--preview-features malware-check&lt;/code&gt; flag is merely a stamp that turns off the warning.&lt;/strong&gt; It is not "you must turn on the preview flag to turn on the check." This distinction may look trivial, but it's exactly what you need to know when debugging "why isn't the check running" or "why did it suddenly start running" in CI.&lt;/p&gt;

&lt;p&gt;The packages in my throwaway project were merely old, not known malicious packages, so the gate quietly let them through and installed all 13 normally. How it blocks when something &lt;em&gt;is&lt;/em&gt; judged malicious, I confirmed from the abort message above and the binary implementation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!WARNING]&lt;br&gt;
An honest limitation — I &lt;strong&gt;did not force a true positive by actually installing a real malicious package.&lt;/strong&gt; Pulling known malware onto my own machine isn't safe. I confirmed that the check activates, that the gate sits on the install path, and that a blocking message exists — but I did not reproduce a positive detection itself. Where the article says it "blocks," that's a description grounded in the design and the message, not in a triggered detection.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A boundary narrower than expected
&lt;/h2&gt;

&lt;p&gt;Up to this point it feels reassuring. But once you learn the things this gate does &lt;em&gt;not&lt;/em&gt; block, you realize you have to use the phrase "security gate" carefully. The "no-fly list" analogy from earlier earns its keep here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It does not detect yanked files.&lt;/strong&gt; The &lt;em&gt;adverse status&lt;/em&gt; in the "no adverse project statuses" you saw in the &lt;code&gt;uv audit&lt;/code&gt; output refers to the &lt;strong&gt;project-wide&lt;/strong&gt; states defined by &lt;a href="https://peps.python.org/pep-0792/" rel="noopener noreferrer"&gt;PEP 792&lt;/a&gt; (&lt;code&gt;archived&lt;/code&gt;, &lt;code&gt;deprecated&lt;/code&gt;, &lt;code&gt;quarantined&lt;/code&gt;). Think of it like a "Closed" or "No Entry" sign on a shop's front door. A &lt;strong&gt;yank&lt;/strong&gt;, by contrast — the mark PyPI attaches to a specific distribution file to say "don't pick this one for new installs," like a "discontinued" sticker slapped on one particular production batch — is per &lt;em&gt;individual file&lt;/em&gt;. As of June 2026, &lt;code&gt;uv audit&lt;/code&gt; does not report these file-level yanks. The very scenario from the opening — "it was recalled, yet the lockfile keeps pointing at it" — is still a blind spot for this tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The malicious-package gate only looks at PyPI-sourced packages.&lt;/strong&gt; In the binary implementation, the malware check queries OSV only for packages in the lockfile that it judges to be from the PyPI registry. So the following currently fall outside the check.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dependencies pinned by direct URL (PEP 508 direct URL)&lt;/li&gt;
&lt;li&gt;Git dependencies&lt;/li&gt;
&lt;li&gt;local paths and wheel files&lt;/li&gt;
&lt;li&gt;private registries&lt;/li&gt;
&lt;li&gt;public PyPI packages relayed through a private mirror&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last item is especially dangerous. An organization whose internal mirror simply relays public PyPI is prone to assume "these are PyPI packages, so of course they get checked" — but if the source looks like a mirror, they're skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Conversely, the vulnerability lookup casts too wide a net.&lt;/strong&gt; The vulnerability-lookup side of &lt;code&gt;uv audit&lt;/code&gt; sends every auditable name and version to OSV with no source filter. As a result, (1) the names of your internal private packages can leave for an external OSV service, and (2) if such a name collides with an unrelated package on public PyPI, the wrong advisory can match. An organization for which external transmission is a problem should point &lt;code&gt;--service-url&lt;/code&gt; or &lt;code&gt;UV_MALWARE_CHECK_URL&lt;/code&gt; at an internal proxy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The lockfile is pinned, but the security verdict drifts.&lt;/strong&gt; This is intended behavior rather than a defect, but in operations it becomes a trap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;same Git commit + same uv.lock
  Monday:  no advisory in OSV       → pass
  Tuesday: new advisory published   → fail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You didn't change a single line of code, yet the build suddenly breaks. So it's better to split the audit into a &lt;strong&gt;PR check&lt;/strong&gt; (does a new dependency bring in new risk?) and a &lt;strong&gt;scheduled audit on the default branch&lt;/strong&gt; (has an advisory published after the fact attached itself to the existing lock?). If you try to catch everything in one place, it gets hard to explain why a PR that was fine yesterday is red today.&lt;/p&gt;

&lt;p&gt;Putting these four together into a single picture looks like this.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Diagram:&lt;/strong&gt; this section is illustrated with a flowchart — &lt;a href="https://var.gg/en/blog/uv-audit-supply-chain" rel="noopener noreferrer"&gt;view it in the original article on var.gg&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  So how does it sit next to other tools
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;uv audit&lt;/code&gt; alone isn't the end of supply-chain security. Here's how to place it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Data / input&lt;/th&gt;
&lt;th&gt;Malicious packages&lt;/th&gt;
&lt;th&gt;CI output&lt;/th&gt;
&lt;th&gt;Block point&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;uv audit + malware check&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;vulns = OSV, status = index (PEP 792), uses &lt;code&gt;uv.lock&lt;/code&gt; directly&lt;/td&gt;
&lt;td&gt;checks &lt;code&gt;MAL-&lt;/code&gt; just before install&lt;/td&gt;
&lt;td&gt;text, JSON, SARIF&lt;/td&gt;
&lt;td&gt;local/CI audit + just before &lt;code&gt;uv sync&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;pip-audit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PyPA advisories by default, OSV optional; requirements / environment / pyproject inputs&lt;/td&gt;
&lt;td&gt;official docs state it "is not a malware-defense tool"&lt;/td&gt;
&lt;td&gt;columns, JSON, CycloneDX (no native SARIF today)&lt;/td&gt;
&lt;td&gt;standalone scanner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Safety&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;managed vulnerability and malicious data&lt;/td&gt;
&lt;td&gt;supports a malicious list&lt;/td&gt;
&lt;td&gt;JSON, HTML, SPDX, etc.&lt;/td&gt;
&lt;td&gt;CLI scanner (install blocking is a separate product)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dependabot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub Advisory DB + dependency graph&lt;/td&gt;
&lt;td&gt;Python malicious alerts don't apply today (npm-centric)&lt;/td&gt;
&lt;td&gt;GitHub UI, PR&lt;/td&gt;
&lt;td&gt;after a repo push, at the PR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;osv-scanner&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OSV; broadly across &lt;code&gt;uv.lock&lt;/code&gt;, requirements, SBOM, even containers&lt;/td&gt;
&lt;td&gt;can detect OSV's &lt;code&gt;MAL-&lt;/code&gt; as an ordinary finding&lt;/td&gt;
&lt;td&gt;JSON, SARIF, CycloneDX&lt;/td&gt;
&lt;td&gt;local/CI scanner&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Distilled to the essentials: &lt;code&gt;pip-audit&lt;/code&gt; is the most direct comparison to uv. &lt;code&gt;osv-scanner&lt;/code&gt; reads &lt;code&gt;uv.lock&lt;/code&gt; directly and emits SARIF, so it's good as an independent second check. Dependabot is strong for update PRs and long-term maintenance, but it isn't an install gate. And uv's real differentiator isn't a bigger database — it's the &lt;strong&gt;position: the tool that knows the dependency graph best has started to hold the decision right at the moment just before the actual install&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  So should you turn it on now
&lt;/h2&gt;

&lt;p&gt;Given that these are preview features, here's a realistic order of operations.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pin&lt;/strong&gt; uv to a single version (e.g., 0.11.23), and write your parsers loosely, assuming the preview output formats can change at any time.&lt;/li&gt;
&lt;li&gt;Start by putting &lt;code&gt;uv audit --frozen&lt;/code&gt; into PR checks and scheduled audits. Reporting is safer than blocking.&lt;/li&gt;
&lt;li&gt;Rather than forcing the malicious-package gate (&lt;code&gt;UV_MALWARE_CHECK=1&lt;/code&gt;) across every developer's machine, turn it on &lt;strong&gt;first in the CI and release builders, where it's easy to roll back.&lt;/strong&gt; Since a failed OSV call currently means the install is blocked (a hard failure), you should first agree on whether "an OSV outage is allowed to block our releases."&lt;/li&gt;
&lt;li&gt;To keep uv alone from becoming a single point of failure, run &lt;code&gt;osv-scanner&lt;/code&gt; or Dependabot alongside it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  In closing
&lt;/h2&gt;

&lt;p&gt;The significance of &lt;code&gt;uv audit&lt;/code&gt; and the malicious-package gate isn't that they invented a new vulnerability database. OSV was already there, and both &lt;code&gt;pip-audit&lt;/code&gt; and osv-scanner look at the same place. What changed is the &lt;strong&gt;position&lt;/strong&gt;. The very tool that fetches and installs packages can now, right before fetching, stop and say "this one's on a list."&lt;/p&gt;

&lt;p&gt;That pause, though, is still a preview-stage guardrail that blocks "known bad names and versions." It's neither forensics that analyzes a package's contents nor a complete supply-chain defense line. Yanks, direct URLs, private mirrors — all left out. So reading this feature as "uv now protects the supply chain" is dangerous, while reading it as "&lt;strong&gt;the install tool has, for the first time, begun to look at the list&lt;/strong&gt;" is accurate. Turning it on knowing that difference, versus turning it on without knowing it, puts you in an entirely different place when an incident hits.&lt;/p&gt;

&lt;p&gt;If you enjoy probing where a supply chain's guarantees actually end, that same lens is worth turning on container signing — cracking open a signature bundle yourself rewards exactly the same skepticism. The gap between what a tool says it "does for you" and what it actually guarantees only ever becomes visible once you've run it yourself.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The measurements in this article are as of June 20, 2026, on uv 0.11.23. Because these are preview features, flags, output, and policy may change in later releases. Official sources: &lt;a href="https://astral.sh/blog/uv-audit" rel="noopener noreferrer"&gt;Astral blog&lt;/a&gt;, &lt;a href="https://github.com/astral-sh/uv/releases" rel="noopener noreferrer"&gt;uv release notes&lt;/a&gt;, &lt;a href="https://osv.dev/" rel="noopener noreferrer"&gt;OSV&lt;/a&gt;, &lt;a href="https://github.com/pypa/pip-audit" rel="noopener noreferrer"&gt;pip-audit&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://var.gg/en/blog/uv-audit-supply-chain" rel="noopener noreferrer"&gt;var.gg&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>security</category>
      <category>devops</category>
      <category>supplychain</category>
    </item>
    <item>
      <title>Vite 8's Rolldown Makes Builds 13x Faster, Not the Dev Server</title>
      <dc:creator>curioustore</dc:creator>
      <pubDate>Fri, 10 Jul 2026 14:38:19 +0000</pubDate>
      <link>https://dev.to/curioustore_48788631d0e2e/vite-8s-rolldown-makes-builds-13x-faster-not-the-dev-server-2l3a</link>
      <guid>https://dev.to/curioustore_48788631d0e2e/vite-8s-rolldown-makes-builds-13x-faster-not-the-dev-server-2l3a</guid>
      <description>&lt;p&gt;When a new version ships, I usually just type &lt;code&gt;npm install&lt;/code&gt; and move on. But installing Vite 8 and looking inside &lt;code&gt;node_modules&lt;/code&gt; made me stop for a second. The two names that had always been installed alongside Vite — &lt;strong&gt;rollup&lt;/strong&gt; and &lt;strong&gt;esbuild&lt;/strong&gt; — were gone entirely. In their place sat a single unfamiliar line: &lt;code&gt;rolldown&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's the heart of Vite 8 (stable release, March 2026). It usually gets summed up as "Vite got faster with Rust," but what actually happened is more specific — and more interesting — than that. For years, Vite ran two separate engines: &lt;strong&gt;one for development&lt;/strong&gt; and &lt;strong&gt;another for bundling production builds&lt;/strong&gt;. Vite 8 merged them into &lt;strong&gt;a single Rust bundler called Rolldown.&lt;/strong&gt; It's less an engine swap than a consolidation — where there used to be two vehicles, now there's one.&lt;/p&gt;

&lt;p&gt;Here's the conclusion in one line: &lt;strong&gt;the speedup is real, but it lands on the "production build," not the "dev server."&lt;/strong&gt; What you have to verify in exchange is the real subject of this post. With benchmarks var.gg built firsthand, I'll walk through what changed and what stayed the same.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!NOTE]&lt;br&gt;
Every measurement here comes from &lt;strong&gt;a representative Vite app freshly scaffolded on local Windows (Node v24)&lt;/strong&gt;. var.gg's own frontend runs on Next.js, not Vite, so this isn't a "we migrated our production app" story. Instead it's a &lt;strong&gt;reproducible benchmark&lt;/strong&gt;: the same source tree placed on Vite 7 and Vite 8 in turn, compared on the same machine. So read the numbers as a &lt;em&gt;direction&lt;/em&gt;, not a promise — on a large real-world app the multipliers can differ.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  So what is Vite, and why did it have two engines?
&lt;/h2&gt;

&lt;p&gt;Let me start by unpacking the unfamiliar terms in plain language, for readers seeing them for the first time.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bundler&lt;/strong&gt;: a tool that takes the dozens to thousands of scattered JS, CSS, and image files and packs them so the browser can fetch them quickly, stripping out unnecessary code along the way. Think of it as the person who packs your moving boxes efficiently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vite&lt;/strong&gt;: a tool that wraps that bundler together with the &lt;strong&gt;dev server&lt;/strong&gt; and &lt;strong&gt;production build&lt;/strong&gt; a frontend project needs, all in one package. It's the de facto starting point for React, Vue, and Svelte projects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HMR (Hot Module Replacement)&lt;/strong&gt;: when you edit and save code, it swaps &lt;strong&gt;only the changed part&lt;/strong&gt; into the page instead of reloading the whole browser. It's what makes development &lt;em&gt;feel&lt;/em&gt; fast.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One important fact here: a frontend toolchain has &lt;strong&gt;two jobs with opposite personalities&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The first is the job &lt;strong&gt;during development&lt;/strong&gt;. The moment a developer saves, it has to respond instantly. It doesn't need to be perfectly optimized — it just has to be fast, period. Vite handed this job to &lt;strong&gt;esbuild&lt;/strong&gt; (an ultra-fast transformer written in Go).&lt;/p&gt;

&lt;p&gt;The second is the job &lt;strong&gt;at deployment time&lt;/strong&gt;. The code you ship to real users has to be split into small pieces (code splitting), stripped of what it doesn't use (tree shaking), compressed, and given a debugging map (source maps). Here &lt;strong&gt;precision&lt;/strong&gt; matters more than raw speed. Vite handed this job to &lt;strong&gt;Rollup&lt;/strong&gt; (a JavaScript-based bundler with the most mature ecosystem of the bunch).&lt;/p&gt;

&lt;p&gt;Here's the analogy. The old Vite was a restaurant with &lt;strong&gt;an ultra-fast prep counter for cooking during service (esbuild) and a separate, meticulous packing-and-logistics line for shipping (Rollup)&lt;/strong&gt;. It was a fast, sensible arrangement. But when the prep counter and the packing line play by different rules, you get headaches — the kind of bug where &lt;strong&gt;it worked fine in development but behaves differently once deployed.&lt;/strong&gt; The same code runs through two different pipelines.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed — pinning down "old X → new Y" exactly
&lt;/h2&gt;

&lt;p&gt;Vite 8 rewrote those two lines as one family called &lt;strong&gt;Rolldown/Oxc&lt;/strong&gt;. Let's separate the two names first.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rolldown&lt;/strong&gt;: a bundler written in Rust. Its goal is "Rollup-compatible usage plus esbuild-class speed." It's the &lt;strong&gt;assembly line&lt;/strong&gt; that bundles files and produces chunks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Oxc (JavaScript Oxidation Compiler)&lt;/strong&gt;: a suite of compiler tools written in Rust — a parser that reads JS/TS, a transformer, a minifier, and so on. It's the set of &lt;strong&gt;cutters, gauges, and quality inspectors&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Their relationship can be captured like this.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Diagram:&lt;/strong&gt; this section is illustrated with a flowchart — &lt;a href="https://var.gg/en/blog/vite-8-rolldown" rel="noopener noreferrer"&gt;view it in the original article on var.gg&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But here's &lt;strong&gt;a point you have to get exactly right&lt;/strong&gt;. It's easy to smear over with a marketing one-liner, so I checked the official migration docs myself. Here's the conclusion.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Through Vite 7&lt;/th&gt;
&lt;th&gt;Vite 8&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Production build&lt;/td&gt;
&lt;td&gt;Rollup&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Rolldown&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependency optimization (pre-bundling)&lt;/td&gt;
&lt;td&gt;esbuild&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Rolldown&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JS/TS/JSX transform&lt;/td&gt;
&lt;td&gt;esbuild&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Oxc&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JS minify&lt;/td&gt;
&lt;td&gt;esbuild&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Oxc Minifier&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSS minify&lt;/td&gt;
&lt;td&gt;esbuild&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Lightning CSS&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config file bundling&lt;/td&gt;
&lt;td&gt;esbuild&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Rolldown&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dev server model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;native ESM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;still native ESM&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the key one. &lt;strong&gt;The dev server did not switch to "bundling all your code up front the way a production build does."&lt;/strong&gt; Vite's default dev server still runs on the model that leans on the browser's native ES modules, and the so-called "Full Bundle Mode" that pre-bundles everything is carved out as &lt;strong&gt;an experimental/future feature, not the default.&lt;/strong&gt; So it's wrong to write "Vite 8 unified development and deployment under a single bundler." The precise sentence is: &lt;strong&gt;"the internal substrate for bundling, transforming, and optimizing was consolidated onto Rolldown/Oxc."&lt;/strong&gt; Why that distinction matters shows up as numbers in the benchmark shortly.&lt;/p&gt;

&lt;p&gt;One more thing. Back in the Vite 7 era there was a separate &lt;code&gt;rolldown-vite&lt;/code&gt; package for people who wanted to try &lt;code&gt;rolldown&lt;/code&gt; early (an opt-in experimental build). In Vite 8 that's &lt;strong&gt;been folded into the main &lt;code&gt;vite&lt;/code&gt; package itself.&lt;/strong&gt; So a new project doesn't need to reach for &lt;code&gt;rolldown-vite&lt;/code&gt; separately — just install Vite 8 and you're on the Rolldown path.&lt;/p&gt;

&lt;h2&gt;
  
  
  I ran it myself — packages vanished, the build got 13× faster
&lt;/h2&gt;

&lt;p&gt;From here on, these aren't estimates — they're results I verified firsthand. I took the same React app (a 24-component dashboard using the recharts chart library and lucide-react icons), placed it on just two versions — &lt;strong&gt;Vite 7.3.5&lt;/strong&gt; and &lt;strong&gt;Vite 8.0.16&lt;/strong&gt; — and measured on the same Windows machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  (1) Two engines collapse into one binary
&lt;/h3&gt;

&lt;p&gt;Look at &lt;code&gt;node_modules/.bin&lt;/code&gt; right after install and the difference is right there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Vite 7  →  .bin = { esbuild, rollup, vite }     packages installed: 107
Vite 8  →  .bin = { rolldown, vite }            packages installed:  62
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;esbuild and rollup are gone, leaving rolldown alone. Checking the dependencies on the npm registry tells the same story — &lt;code&gt;vite@8.0.16&lt;/code&gt; directly depends on a single package, &lt;code&gt;rolldown&lt;/code&gt;, while &lt;code&gt;vite@7&lt;/code&gt; directly depends on both &lt;code&gt;rollup&lt;/code&gt; and &lt;code&gt;esbuild&lt;/code&gt;. As the platform-specific binary packages of the two engines merged into one Rolldown, &lt;strong&gt;the number of installed packages fell from 107 to 62.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  (2) The build got ~13× faster (stable across repeated runs)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Vite 7 (Rollup)&lt;/th&gt;
&lt;th&gt;Vite 8 (Rolldown)&lt;/th&gt;
&lt;th&gt;Multiple&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Production build, cold&lt;/td&gt;
&lt;td&gt;2230 ms&lt;/td&gt;
&lt;td&gt;167 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~13.4×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production build, warm&lt;/td&gt;
&lt;td&gt;2210 ms&lt;/td&gt;
&lt;td&gt;153 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~14.4×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev server ready (cold deps)&lt;/td&gt;
&lt;td&gt;710 ms&lt;/td&gt;
&lt;td&gt;954 ms&lt;/td&gt;
&lt;td&gt;0.74× (8 is slower)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dev server ready (warm cache)&lt;/td&gt;
&lt;td&gt;189 ms&lt;/td&gt;
&lt;td&gt;137 ms&lt;/td&gt;
&lt;td&gt;1.38× (8 is faster)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;How you read this matters. &lt;strong&gt;The 13–14× on the production build is a rock-solid number that doesn't wobble across repeated runs.&lt;/strong&gt; What took Rollup about 2.2 seconds, Rolldown finishes in the 0.15-second range.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;dev server startup time, by contrast, is not a story.&lt;/strong&gt; Depending on cache state, 8 was slower (cold) or faster (warm). It's just jitter within the same ballpark. And that's expected — the esbuild that backed the dev server &lt;strong&gt;was already plenty fast.&lt;/strong&gt; Swapping in Rolldown left no room for the dev server to get dramatically faster in the first place. What sped up is the &lt;strong&gt;production build&lt;/strong&gt;, where Rollup was the bottleneck.&lt;/p&gt;

&lt;p&gt;This meshes exactly with the point I stressed earlier — "the dev server model is unchanged." &lt;strong&gt;The 10× is real, but it lives behind the deploy button, not the save button.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  (3) The 13× isn't the result of skipping work
&lt;/h3&gt;

&lt;p&gt;Fast is meaningless if the output is flimsy. So I compared what the two builds produced.&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;JS bundle output&lt;/th&gt;
&lt;th&gt;Build success&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vite 7&lt;/td&gt;
&lt;td&gt;602,411 B&lt;/td&gt;
&lt;td&gt;✅ exit 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vite 8&lt;/td&gt;
&lt;td&gt;588,318 B&lt;/td&gt;
&lt;td&gt;✅ exit 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both produced a single bundle cleanly, and the sizes are nearly identical (Rolldown's is actually 2.3% smaller). It &lt;strong&gt;produced the same output 13× faster&lt;/strong&gt; — it isn't fast because it skipped something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is "10–30×" real? Only if you keep the conditions attached
&lt;/h2&gt;

&lt;p&gt;The official Vite 8 announcement headlines "10–30× faster builds than Rollup." It also cites real preview cases — one app's build dropping from 46 seconds to 6, that sort of figure. These numbers aren't false. But &lt;strong&gt;stripped of their conditions, they turn into an overstatement.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Look at the public Rolldown benchmark repo and the app under test is a &lt;strong&gt;massive module graph&lt;/strong&gt; — think 10,000 JSX components plus 9,000 dependency modules. At that scale Rollup takes over 100 seconds, while Rolldown finishes in the low single-digit seconds. The "10–30×" comes precisely from that large graph, and specifically from the &lt;strong&gt;production build&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Our benchmark was a mere 24-component app and still showed 13× on the build. If anything, that's good evidence that &lt;strong&gt;the build gap is real even on small apps&lt;/strong&gt;. That said, on an even smaller app — an empty template — &lt;strong&gt;other bottlenecks&lt;/strong&gt; like &lt;code&gt;tsc&lt;/code&gt; type checking, plugin overhead, the Windows file watcher, and antivirus loom larger, so you might not see 13×. The honest sentence is: &lt;strong&gt;the bigger the app, the bigger the gain, and promising a flat 30× on an empty template is an overstatement.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  So does anything break? "Mostly works" isn't "works without checking"
&lt;/h2&gt;

&lt;p&gt;You swapped out the entire bundler — will all your existing plugins just keep running? This is where the real cost of migration hides.&lt;/p&gt;

&lt;p&gt;Rolldown is designed to be &lt;strong&gt;compatible with Rollup's plugin API.&lt;/strong&gt; So most Vite plugins work as-is. But the official docs' phrasing is "almost fully compatible." The &lt;strong&gt;"almost"&lt;/strong&gt; is the crux. One signal I checked myself: the official React plugin already got a major version bump.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@vitejs/plugin-react@5.x  →  peer: vite ^4|5|6|7  (Rollup/esbuild world)
@vitejs/plugin-react@6.x  →  peer: vite ^8         (depends on @rolldown/plugin-babel)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In other words, moving to Vite 8 means bumping the React plugin from 5 to 6 as well, and internally the babel wiring switches to a Rolldown-native plugin. &lt;strong&gt;It's no guarantee that "every Rollup plugin works without changing a single character."&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!WARNING]&lt;br&gt;
Representative items to run through a migration checklist before upgrading Vite 7 → 8 (per the official migration guide):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;build.rollupOptions&lt;/code&gt; → &lt;code&gt;build.rolldownOptions&lt;/code&gt;&lt;/strong&gt; — the name changed (the old one is deprecated).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The object form of &lt;code&gt;manualChunks&lt;/code&gt;&lt;/strong&gt; is unsupported — if you've built a vendor-chunk splitting strategy, it may break.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some Rollup hooks are unsupported&lt;/strong&gt; (&lt;code&gt;shouldTransformCachedModule&lt;/code&gt;, &lt;code&gt;resolveImportMeta&lt;/code&gt;, and others) — plugins that finely control advanced caching, asset URLs, or dynamic imports need checking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;system&lt;/code&gt;/&lt;code&gt;amd&lt;/code&gt; output formats are unsupported&lt;/strong&gt;, and &lt;strong&gt;plugin-legacy's transpilation down to ES5 or below is unsupported&lt;/strong&gt; — hold off if your app must support very old browsers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CJS interop and minifier output differ&lt;/strong&gt; — the Oxc minifier and the esbuild minifier make different assumptions, so the compressed result isn't byte-for-byte identical. Visual regression tests are recommended.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;To sum up: on compatibility, "mostly works" is the right characterization. But that's different from &lt;strong&gt;"safe to upgrade without checking."&lt;/strong&gt; The more plugin-dependent your app, the more that gap comes back as cost.&lt;/p&gt;

&lt;p&gt;There's one less-obvious trade-off here. Measuring the disk myself: &lt;strong&gt;the package count dropped (107 → 62), but the single Rust binary was heavier.&lt;/strong&gt; Rolldown's binary at ~23MB plus Lightning CSS at ~10MB is larger than the old esbuild's 11MB plus Rollup's 5MB (the official announcement itself notes that install size grew by ~15MB). Fewer &lt;strong&gt;packages&lt;/strong&gt; doesn't mean a smaller disk footprint — you trade for speed, but the binary got bigger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you migrate now? Conclusions by situation
&lt;/h2&gt;

&lt;p&gt;In the face of a big tooling shift, the answer is always "it depends." Broken down concretely:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;New Vite project&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Just start with Vite 8. No reason to reach for &lt;code&gt;rolldown-vite&lt;/code&gt; separately.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Small Vite 7 app&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Good chance you can upgrade straight to 8 once build, tests, and preview pass in CI.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plugin-heavy app&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Safer in two steps: first isolate-test "Vite 7 + Rolldown" via &lt;code&gt;rolldown-vite&lt;/code&gt;, then move up to 8.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;App with complex custom Rollup output/hooks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Start by cross-checking the unsupported items in the WARNING above. Don't upgrade blindly.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Next.js users (= var.gg)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read this less as a direct migration issue than as "where build tooling is heading." Next.js defaults to &lt;strong&gt;Turbopack&lt;/strong&gt;, a bundler from the same Rust lineage.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is where var.gg sits. Our frontend is Next.js, so this post isn't a record of "migrating our app." The reason I ran this verification anyway is the &lt;strong&gt;larger current of core JS-ecosystem tools being rewritten in native languages (Rust/Go).&lt;/strong&gt; It's an event of exactly the same character as when I recently &lt;a href="https://var.gg/en/blog/typescript-native-tsgo" rel="noopener noreferrer"&gt;benchmarked the TypeScript compiler's native rewrite firsthand&lt;/a&gt; — instead of trusting the marketing copy, you run it yourself on a safe copy and confirm "what actually changes in our case." What a new tool absorbs and what it leaves untouched is something you can only really settle by getting hands-on with each beta or new release and running it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up — not "fast because Rust" but "the internal contract changed"
&lt;/h2&gt;

&lt;p&gt;To restate the heart of Vite 8 in one line: it's not "fast because Rust." It's that Vite &lt;strong&gt;rewrote&lt;/strong&gt; the dual-engine structure it had long maintained — &lt;strong&gt;esbuild for development plus Rollup for deployment&lt;/strong&gt; — around Rolldown/Oxc. The biggest promise of that shift isn't only speed; it's shrinking the difference in how modules are handled between development and deployment, inheriting as much of the Rollup plugin ecosystem as possible, and moving the internal implementation to Rust.&lt;/p&gt;

&lt;p&gt;And running it myself gives a more honest picture than the marketing. The build was 13× faster even on a small app; the dev server, already fast, stayed roughly the same; and plugins "mostly" work, but a major version bump and a handful of unsupported items come along for the ride. For a new project there's no reason to hesitate; for an existing app, verify in stages according to its size and plugin dependence. As with every big tooling shift, &lt;strong&gt;the most accurate basis for judgment isn't the marketing copy — it's a single build you ran yourself.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://var.gg/en/blog/vite-8-rolldown" rel="noopener noreferrer"&gt;var.gg&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>vite</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>Node.js 26 Enables Temporal by Default — Time to Ditch Date?</title>
      <dc:creator>curioustore</dc:creator>
      <pubDate>Fri, 10 Jul 2026 14:35:54 +0000</pubDate>
      <link>https://dev.to/curioustore_48788631d0e2e/nodejs-26-enables-temporal-by-default-time-to-ditch-date-260l</link>
      <guid>https://dev.to/curioustore_48788631d0e2e/nodejs-26-enables-temporal-by-default-time-to-ditch-date-260l</guid>
      <description>&lt;p&gt;The morning after a deploy, the billing report arrived with an entire day blanked out. The code was one unremarkable line that pulled "from yesterday's midnight to today's midnight" — and that weekend, of all weekends, was the daylight-saving switchover. That day was 23 hours long, not 24, and the "24 hours later" I'd added with &lt;code&gt;Date&lt;/code&gt; overshot the calendar midnight by one notch. It wasn't anyone's mistake. This is simply how JavaScript's &lt;code&gt;Date&lt;/code&gt; behaves.&lt;/p&gt;

&lt;p&gt;If you've ever been burned like this, you've probably heard the name &lt;code&gt;Temporal&lt;/code&gt; at least once. It's a new time API that has been going through standardization for years as a replacement for &lt;code&gt;Date&lt;/code&gt;. Until now you could only touch it by installing a polyfill or flipping an experimental flag — but &lt;strong&gt;as of Node.js 26, released on May 5, 2026, it is simply on, with no configuration at all.&lt;/strong&gt; The global &lt;code&gt;Temporal&lt;/code&gt; object is right there from the start, just like &lt;code&gt;JSON&lt;/code&gt; or &lt;code&gt;Math&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So I grabbed a portable Node 26 build and swapped it in myself. This isn't a release-notes roundup of "what's new in the latest version" — it's a hands-on record of why &lt;code&gt;Date&lt;/code&gt; earned all that grief, how Temporal solves the same problems differently, and &lt;strong&gt;whether you can switch right now.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened — Node 26 just "turned Temporal on"
&lt;/h2&gt;

&lt;p&gt;First, the facts. I measured on the current latest, &lt;strong&gt;Node v26.4.0&lt;/strong&gt; (2026-06-24).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node v26.4.0  | Temporal global? true
v8 14.6.202.34-node.21 | undici 8.5.0 | npm 11.17.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single line — &lt;code&gt;typeof Temporal&lt;/code&gt; returning something other than &lt;code&gt;undefined&lt;/code&gt; — is where this post begins. Temporal was a long-ripening proposal at TC39 (the JavaScript standards committee), and Node 26, by bumping its V8 engine to 14.6, promoted it to &lt;strong&gt;exposed by default, no flag required&lt;/strong&gt; (per the official release notes, &lt;a href="https://github.com/nodejs/node/pull/61806" rel="noopener noreferrer"&gt;#61806&lt;/a&gt;). Browsers are following the same V8 lineage, so Temporal's status has shifted from "a standard someday" to "something now baked into the runtime."&lt;/p&gt;

&lt;p&gt;This is also part of a broader pattern across today's runtimes. Much like &lt;a href="https://var.gg/en/blog/python-315-utf8-default" rel="noopener noreferrer"&gt;Python 3.15 recently switching the default encoding for text I/O to UTF-8&lt;/a&gt;, more and more languages and runtimes are biting the bullet and replacing "a bad default they'd tolerated for years" in a major release. Turning Temporal on by default is a decision cut from the same cloth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wait, what is Temporal?
&lt;/h2&gt;

&lt;p&gt;Let's start with an analogy. &lt;code&gt;Date&lt;/code&gt; is &lt;strong&gt;an all-in-one Swiss Army knife that crams everything onto a single blade.&lt;/strong&gt; "This exact moment" is a &lt;code&gt;Date&lt;/code&gt;, "my birthday, June 29" is a &lt;code&gt;Date&lt;/code&gt;, "the meeting is at 3 PM Seoul time" is also expressed with that same one &lt;code&gt;Date&lt;/code&gt;. The trouble is that these three are really completely different kinds of concept. A birthday should carry no time zone, "this exact moment" is a single point that's identical everywhere on Earth, and "3 PM in Seoul" only means anything with a time zone attached. When one type has to be all three, something is always slightly off.&lt;/p&gt;

&lt;p&gt;Temporal &lt;strong&gt;splits that knife into a toolbox with a dedicated tool for each job.&lt;/strong&gt; Boiled down to the core types, it looks like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Temporal type&lt;/th&gt;
&lt;th&gt;What it represents&lt;/th&gt;
&lt;th&gt;Everyday analogy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Instant&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An absolute point in time with no zone (one instant the whole world shares)&lt;/td&gt;
&lt;td&gt;The exact shutter moment frozen in a photo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PlainDate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A date with no time and no time zone&lt;/td&gt;
&lt;td&gt;A day circled on a calendar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PlainDateTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A date and time with no time zone&lt;/td&gt;
&lt;td&gt;A note that just says "3 PM"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ZonedDateTime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A complete moment with the time zone attached&lt;/td&gt;
&lt;td&gt;A plane ticket reading "3 PM Seoul"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Duration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A span of time (like 2 days 3 hours)&lt;/td&gt;
&lt;td&gt;Elapsed time on a stopwatch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You might wonder, "Why split it this finely?" The next few sections are the answer. Once it's split, the things a single &lt;code&gt;Date&lt;/code&gt; used to get quietly wrong &lt;strong&gt;either can't be mixed up in the first place, or blow up as an error the moment they are.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Date has taken 30 years of flak
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Date&lt;/code&gt; is an API copied wholesale from Java's already-dated &lt;code&gt;Date&lt;/code&gt; back in the earliest days of JavaScript, in 1995. Those fingerprints are still there, still tripping people up. I ran three of them myself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1) Months count from zero.&lt;/strong&gt; A human writes July as &lt;code&gt;7&lt;/code&gt;, but &lt;code&gt;Date&lt;/code&gt; writes it as &lt;code&gt;6&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;new Date(2026, 6, 29)  =&amp;gt; Wed Jul 29 2026   // put in 6, get July
Temporal {month: 7}    =&amp;gt; 2026-07-29         // put in 7, get July (1-based)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2) Values change out from under you (mutability).&lt;/strong&gt; With &lt;code&gt;Date&lt;/code&gt;, methods like &lt;code&gt;setMonth&lt;/code&gt; modify the original in place. Hand a &lt;code&gt;Date&lt;/code&gt; off somewhere, and if another function changes it, your own variable changes too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Date.setMonth(+1)   → original mutates to 2026-08-29             // share it and it's a bug
Temporal.add(+1m)   → keeps original 2026-07-29, returns new 2026-08-29  // immutable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3) String parsing is interpreted differently depending on the format.&lt;/strong&gt; Write the same date slightly differently, and the time-zone interpretation diverges.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new Date("2026-06-29")  =&amp;gt; 2026-06-29T00:00:00.000Z          // UTC midnight
new Date("2026/06/29")  =&amp;gt; Mon Jun 29 2026 00:00 GMT+0900    // local midnight (different!)
Temporal.PlainDate.from("2026-06-29")  =&amp;gt; 2026-06-29          // no concept of a time zone at all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You only swapped a &lt;code&gt;-&lt;/code&gt; for a &lt;code&gt;/&lt;/code&gt;, yet the result is off by nine hours. This kind of thing rarely gets caught in code review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real trap is time zones
&lt;/h2&gt;

&lt;p&gt;You can memorize your way around those three. But the billing incident from the opening — &lt;strong&gt;daylight saving time (DST)&lt;/strong&gt; — is hard to dodge by memorization. This is where the most expensive bugs in &lt;code&gt;Date&lt;/code&gt;-based time handling come from.&lt;/p&gt;

&lt;p&gt;DST is the practice of moving clocks forward an hour in spring (making that day 23 hours) and back an hour in fall (25 hours). Because of it, "one calendar day later" and "physically 24 hours later" &lt;strong&gt;can land on different days.&lt;/strong&gt; I compared them directly at the 2026 US Eastern spring transition (March 8).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;start             : 2026-03-07T12:00:00-05:00[America/New_York]
+1 day (calendar) : 2026-03-08T12:00:00-04:00   // still 12:00 midday — absorbs the DST shift
+24h  (physical)  : 2026-03-08T13:00:00-04:00   // 13:00 — pushed an hour later
Date +24h (ms)    : 13:00 once converted to Eastern   // Date only adds ms, so it's blind to DST
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On &lt;code&gt;Temporal.ZonedDateTime&lt;/code&gt;, &lt;code&gt;add({ days: 1 })&lt;/code&gt; understands "a day as people mean it." So even across the transition, the clock still reads 12:00. &lt;code&gt;add({ hours: 24 })&lt;/code&gt;, on the other hand, is 24 hours of physical time, so it slides to 13:00. &lt;strong&gt;Both are correct.&lt;/strong&gt; The point is that in Temporal you write down explicitly which of the two you want. &lt;code&gt;Date&lt;/code&gt; has nothing but millisecond arithmetic, so only the latter (physical 24 hours) is ever possible — and when you meant "one calendar day," as in that billing batch, it fails silently.&lt;/p&gt;

&lt;p&gt;This is why the types are split. With a zone-less &lt;code&gt;Instant&lt;/code&gt;, you can't add "one calendar day" in the first place — knowing about DST requires knowing which time zone you're in (&lt;code&gt;ZonedDateTime&lt;/code&gt;). Temporal enforces that distinction through the type system.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Diagram:&lt;/strong&gt; this section is illustrated with a flowchart — &lt;a href="https://var.gg/en/blog/nodejs-26-temporal-default" rel="noopener noreferrer"&gt;view it in the original article on var.gg&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Calendar arithmetic: refusing instead of quietly getting it wrong
&lt;/h2&gt;

&lt;p&gt;Some date calculations don't have a single clean answer. What's "one month after January 31"? There is no February 31. &lt;code&gt;Date&lt;/code&gt; &lt;strong&gt;rolls it over into March without a word.&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;Date.setMonth(+1)  (Jan 31) =&amp;gt; Tue Mar 03 2026   // skips February, overflows into March
Temporal.add(+1m)            =&amp;gt; 2026-02-28        // clamped to Feb's last day (constrain)
Temporal.add(+1m, reject)    =&amp;gt; RangeError        // rejects it: "there is no Feb 31"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default (&lt;code&gt;constrain&lt;/code&gt;), Temporal clamps to the last day of February; but hand it &lt;code&gt;{ overflow: 'reject' }&lt;/code&gt; and it &lt;strong&gt;throws an error instead of quietly patching up an invalid date.&lt;/strong&gt; In places like accounting and settlement, where "when in doubt, stop" is the right rule, this difference is decisive. &lt;code&gt;Date&lt;/code&gt; has no notion of "reject" at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  So can you ditch Date? — an honest look at the limits
&lt;/h2&gt;

&lt;p&gt;By this point Temporal might look like a cure-all, but this isn't a call to rip everything out today. There are honest limits, too.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The ecosystem is still catching up.&lt;/strong&gt; Libraries like &lt;code&gt;date-fns&lt;/code&gt;, &lt;code&gt;Luxon&lt;/code&gt;, and &lt;code&gt;Day.js&lt;/code&gt;, along with mountains of existing code, still pass &lt;code&gt;Date&lt;/code&gt; objects around. At the boundaries you'll need conversions like &lt;code&gt;toPlainDate()&lt;/code&gt; / &lt;code&gt;Temporal.Instant.fromEpochMilliseconds()&lt;/code&gt;, and ORMs, DB drivers, and JSON serialization often don't understand Temporal types out of the box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Date&lt;/code&gt; isn't going away.&lt;/strong&gt; Temporal is an option that &lt;em&gt;replaces&lt;/em&gt; &lt;code&gt;Date&lt;/code&gt;, not one that removes it. Existing code keeps running as-is. "New code from here on uses Temporal" is about as realistic as it gets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backward compatibility.&lt;/strong&gt; If you have to support Node below 26, or browsers that haven't enabled Temporal yet, you'll need a polyfill — and the polyfill isn't small.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The learning curve.&lt;/strong&gt; Splitting into five types means dropping the "just use a &lt;code&gt;Date&lt;/code&gt; for anything" habit. At first you'll hesitate every time over whether to reach for &lt;code&gt;PlainDateTime&lt;/code&gt; or &lt;code&gt;ZonedDateTime&lt;/code&gt;. But that hesitation is precisely what Temporal intends — &lt;code&gt;Date&lt;/code&gt; bred bugs by letting you skip the question entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short, Temporal is &lt;strong&gt;not a tool that makes things shallowly easier; it's one that makes things correct the hard way.&lt;/strong&gt; You pay an entry cost, and in return it takes away the chronic pain of time-zone and calendar bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things to check before upgrading to Node 26
&lt;/h2&gt;

&lt;p&gt;Before you jump to Node 26 for Temporal alone, you should account for the compatibility changes that rode in with this major. Here's what I verified firsthand.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Removed legacy APIs.&lt;/strong&gt; &lt;code&gt;http.ServerResponse.prototype.writeHeader&lt;/code&gt; (use &lt;code&gt;writeHead&lt;/code&gt; instead) and the old internal stream modules (&lt;code&gt;_stream_wrap&lt;/code&gt; and friends) are gone for good. &lt;code&gt;require('_stream_wrap')&lt;/code&gt; now genuinely dies with &lt;code&gt;MODULE_NOT_FOUND&lt;/code&gt;. If an aging dependency references it, your upgrade stalls right there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;--experimental-transform-types&lt;/code&gt; flag is removed&lt;/strong&gt;, &lt;code&gt;module.register()&lt;/code&gt; is runtime-deprecated, and the native add-on ABI version (&lt;code&gt;NODE_MODULE_VERSION&lt;/code&gt;) rose to &lt;strong&gt;147&lt;/strong&gt; — compiled native modules need a rebuild. On the build side there's also a bump to GCC 13.2 or newer and the end of Python 3.9 support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nice extras that came along for the ride.&lt;/strong&gt; Because it's V8 14.6, you also get &lt;code&gt;Map.prototype.getOrInsert&lt;/code&gt; / &lt;code&gt;getOrInsertComputed&lt;/code&gt; (one-shot methods that fetch if present and insert if not) and &lt;code&gt;Iterator.concat&lt;/code&gt;. I confirmed all three actually work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The timeline is worth knowing too. &lt;strong&gt;Node 26 is promoted to LTS in October 2026&lt;/strong&gt;, and stays Current until then. And starting with Node 27 the release model changes: &lt;strong&gt;one major per year (in April)&lt;/strong&gt;, with all of them LTS (&lt;a href="https://www.infoq.com/news/2026/06/nodejs-release-changes/" rel="noopener noreferrer"&gt;InfoQ, 2026-06&lt;/a&gt;). In other words, 26 is the last branch of the old model, and Temporal is the biggest change riding on that final branch. For broader context on where the JS runtime ecosystem has been heading lately, I've also covered it separately in pieces on &lt;a href="https://var.gg/en/blog/deno-28-npm-toolchain" rel="noopener noreferrer"&gt;Deno 2.8's cleanup of its npm-compatible toolchain&lt;/a&gt; and &lt;a href="https://var.gg/en/blog/typescript-native-tsgo" rel="noopener noreferrer"&gt;TypeScript's move to a native compiler (tsgo)&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up — when to use Temporal
&lt;/h2&gt;

&lt;p&gt;Here's my conclusion after swapping it in and using it myself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For any new time or date logic, make Temporal the default.&lt;/strong&gt; Especially code where "a silent one-hour or one-day drift leaks money" — time zones, DST, recurring schedules, settlement cutoffs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't rewrite a huge existing &lt;code&gt;Date&lt;/code&gt; codebase all at once&lt;/strong&gt; — start at the boundaries (input parsing, output formatting) and move inward gradually. Converting just the inner calculations to Temporal already makes most of the bugs vanish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For a log that just stamps "the current time" once,&lt;/strong&gt; &lt;code&gt;Date&lt;/code&gt; is perfectly fine. Cramming Temporal into every last corner is its own kind of overkill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;Date&lt;/code&gt; that was hastily copied over 30 years ago now has a successor inside the standard runtime. Node 26 turning it on without a flag is the signal that "migrate someday" has become "evaluate now." If you'd rather not be woken at dawn by the next time-zone bug, I'd start with that one line — the one where &lt;code&gt;typeof Temporal&lt;/code&gt; comes back &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://var.gg/en/blog/nodejs-26-temporal-default" rel="noopener noreferrer"&gt;var.gg&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Ran TypeScript 7's Native Compiler (tsgo) on Our Monorepo</title>
      <dc:creator>curioustore</dc:creator>
      <pubDate>Mon, 29 Jun 2026 14:43:22 +0000</pubDate>
      <link>https://dev.to/curioustore_48788631d0e2e/i-ran-typescript-7s-native-compiler-tsgo-on-our-monorepo-4d6</link>
      <guid>https://dev.to/curioustore_48788631d0e2e/i-ran-typescript-7s-native-compiler-tsgo-on-our-monorepo-4d6</guid>
      <description>&lt;p&gt;I fixed one component and hit save. All I'd changed was a single line of types, yet CI's &lt;code&gt;tsc --noEmit&lt;/code&gt; churned away for ages again. Anyone who has built a large frontend in TypeScript knows the scene. "Type checking is slow" isn't an abstract complaint — it's a wait that gets a little longer every day as your app grows.&lt;/p&gt;

&lt;p&gt;In 2026, an answer to that wait finally arrived. Microsoft &lt;strong&gt;rewrote the entire TypeScript compiler in Go&lt;/strong&gt; as a native version, codenamed &lt;em&gt;Corsa&lt;/em&gt;, with the executable named &lt;code&gt;tsgo&lt;/code&gt;. The headline of the official announcement was "&lt;strong&gt;10x faster&lt;/strong&gt;." So I checked it for myself. I dropped &lt;code&gt;tsgo&lt;/code&gt; straight onto the var.gg frontend monorepo that powers this very blog — a Next.js App Router project with 700-some &lt;code&gt;.ts&lt;/code&gt;/&lt;code&gt;.tsx&lt;/code&gt; files — and ran it side by side with the &lt;code&gt;tsc&lt;/code&gt; we already use.&lt;/p&gt;

&lt;p&gt;To put the conclusion up front: yes, it got faster. But "10x" wasn't what we saw in our code, and the more interesting finding wasn't the speed at all — it was that &lt;strong&gt;the two compilers did not report the same errors.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tsgo&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; "a tool that compiles your TypeScript into native code." It's the TypeScript compiler &lt;em&gt;itself&lt;/em&gt;, &lt;strong&gt;moved from a self-hosted JavaScript implementation to a native Go implementation&lt;/strong&gt;. What it produces (type judgments, &lt;code&gt;.js&lt;/code&gt;, &lt;code&gt;.d.ts&lt;/code&gt;) is the same; the engine that produces it has changed.&lt;/li&gt;
&lt;li&gt;Measured on our monorepo (728 files, &lt;code&gt;skipLibCheck&lt;/code&gt; on): a full type check went from &lt;code&gt;tsc&lt;/code&gt; 6.7s → &lt;code&gt;tsgo&lt;/code&gt; 2.1s, about &lt;strong&gt;3.2x&lt;/strong&gt;. The official "10x" comes from large codebases like VS Code with 1.5 million lines. When a project is small or already optimized, the multiplier shrinks.&lt;/li&gt;
&lt;li&gt;The real discovery wasn't speed but a &lt;strong&gt;diagnostics mismatch&lt;/strong&gt;. &lt;code&gt;tsgo&lt;/code&gt; (the 7.0 dev preview) caught one overload error in our code that &lt;code&gt;tsc&lt;/code&gt; 6.0.3 let through. The first thing to look at in a migration isn't "how fast is it" but "&lt;strong&gt;does it give the same verdict&lt;/strong&gt;."&lt;/li&gt;
&lt;li&gt;As of June 2026, TypeScript 7.0 is in &lt;strong&gt;Beta/preview&lt;/strong&gt;. The stable line is 6.0.x, and &lt;code&gt;tsgo&lt;/code&gt; is a dev build shipped as &lt;code&gt;@typescript/native-preview&lt;/code&gt;. The right strategy right now isn't to &lt;em&gt;replace&lt;/em&gt; &lt;code&gt;tsc&lt;/code&gt;, but to &lt;strong&gt;run &lt;code&gt;tsgo&lt;/code&gt; side by side as a sidecar that measures speed and diagnostics&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;One-line glossary — &lt;strong&gt;compiler&lt;/strong&gt;: a program that transforms and checks human-written code so machines or other formats can work with it. TypeScript's &lt;code&gt;tsc&lt;/code&gt; does less transforming and more "&lt;strong&gt;checking whether the types are correct&lt;/strong&gt;."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The paradox of TypeScript being written in TypeScript
&lt;/h2&gt;

&lt;p&gt;Until now, &lt;code&gt;tsc&lt;/code&gt; was written in TypeScript and ran on JavaScript. Building your own compiler in your own language is called &lt;strong&gt;self-hosting&lt;/strong&gt;. It wasn't a bad choice. The team got to use their own language every day (so-called dogfooding) and grow the feature set quickly.&lt;/p&gt;

&lt;p&gt;The problem was scale. Roughly speaking, here's what the compiler does.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;scanner/parser&lt;/strong&gt;: chops the source — a blob of characters — into tokens and turns it into an &lt;strong&gt;AST&lt;/strong&gt; (Abstract Syntax Tree, a tree-structured representation of source code that's easier for the compiler to work with).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;binder&lt;/strong&gt;: connects a name like &lt;code&gt;foo&lt;/code&gt; to the declaration it refers to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;type checker&lt;/strong&gt;: works out whether the types you wrote contradict the actual values, calls, and inheritance. Calling &lt;code&gt;.toUpperCase()&lt;/code&gt; on a &lt;code&gt;number&lt;/code&gt; gets caught here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;emit&lt;/strong&gt;: from the check results, emits &lt;code&gt;.js&lt;/code&gt; or &lt;code&gt;.d.ts&lt;/code&gt; (a "contract" file that holds only type information, with no JavaScript implementation).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;language service&lt;/strong&gt;: a separate service responsible for the editor's autocomplete, red squiggles, rename, and go-to-definition. It uses the same information as the build.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of this runs sequentially on a single JavaScript runtime (a single thread). As the app grows, you have to hold the AST and the type graph in memory, and the cost of GC (garbage collection) that clears away unneeded objects grows too. By analogy, it's like &lt;strong&gt;one skilled librarian classifying every book in a large library alone, one at a time&lt;/strong&gt;. No matter how good the librarian is, when there are a million books the line gets long.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Go and not Rust — "porting," not "rewriting"
&lt;/h2&gt;

&lt;p&gt;Here's a common misconception. "If they wanted performance, they should have written it in Rust — why Go?" The official FAQ's answer isn't a matter of taste. The key is the difference between &lt;strong&gt;port&lt;/strong&gt; and &lt;strong&gt;rewrite&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The TypeScript team did not redesign the compiler. They moved it to Go while &lt;strong&gt;preserving the behavior and code structure of the existing compiler as much as possible&lt;/strong&gt;. Why does that matter? Because the entire ecosystem depends on the TypeScript type system down to subtle behaviors written in no spec (so-called &lt;em&gt;Hyrum's Law&lt;/em&gt; — with enough users, every behavior of your system, even ones not in the spec, will be depended on by somebody). The moment you redesign, the risk that those subtle behaviors drift goes way up.&lt;/p&gt;

&lt;p&gt;So "how easy is it to port the existing code structure one-to-one" was the top criterion for the language choice. Go is structurally similar to the patterns of the existing TypeScript code, gives enough control over memory layout, and suits tree/graph processing. Rust wasn't eliminated for being slow; more precisely, &lt;strong&gt;redesigning around Rust's ownership and memory model would make it harder to guarantee "the same TypeScript."&lt;/strong&gt; Because compatibility came first, they chose a port over a rewrite.&lt;/p&gt;

&lt;p&gt;So, to stress it again — &lt;code&gt;tsgo&lt;/code&gt; &lt;strong&gt;does not turn your TypeScript into Go or a native binary.&lt;/strong&gt; What changed is the language the compiler is &lt;em&gt;built in&lt;/em&gt;, not the output the compiler &lt;em&gt;produces&lt;/em&gt;. Back to the library analogy: the classification rules stay the same; the change is to &lt;strong&gt;let several librarians split the work across sections while sharing one catalog&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  TypeScript 6.0 as a bridge
&lt;/h2&gt;

&lt;p&gt;To talk about &lt;code&gt;tsgo&lt;/code&gt;, you have to start with &lt;strong&gt;TypeScript 6.0&lt;/strong&gt;. 6.0 is a peculiar release. The official announcement describes it as "the &lt;strong&gt;last&lt;/strong&gt; major release for the existing JavaScript codebase" and a "&lt;strong&gt;bridge&lt;/strong&gt; to crossing over to 7.0's Go native." In fact, there are no plans to make TypeScript 6.1, and the 6.0 line will only receive patches focused on security, severe regressions, and 6/7 compatibility issues.&lt;/p&gt;

&lt;p&gt;What 6.0 does is "clean up the present for the future." It changes defaults (e.g. &lt;code&gt;strict&lt;/code&gt;, &lt;code&gt;types: []&lt;/code&gt;) and marks old options as deprecated. And it gives you a safety pin to silence those warnings temporarily: &lt;code&gt;"ignoreDeprecations": "6.0"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But don't mistake this safety pin for a migration strategy. &lt;strong&gt;In TypeScript 7, those deprecated options aren't supported at all.&lt;/strong&gt; That means the list of warnings you hid in 6.0 is exactly the list of hard-error candidates in 7.0.&lt;/p&gt;

&lt;p&gt;Here we had a small bit of luck. The var.gg frontend is already on &lt;code&gt;typescript ^6.0.3&lt;/code&gt; — that is, we're &lt;em&gt;standing on the bridge.&lt;/em&gt; That's a much further-along starting line than projects still on 5.x. Here are some representative items worth checking ahead of time on 6.x.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Change in 6.0&lt;/th&gt;
&lt;th&gt;7.0 perspective&lt;/th&gt;
&lt;th&gt;Checkpoint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;strict&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;default &lt;code&gt;true&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;strict worldview in 7 too&lt;/td&gt;
&lt;td&gt;little impact if already strict&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;types&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;default &lt;code&gt;[]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;implicit &lt;code&gt;@types/*&lt;/code&gt; inclusion reduced&lt;/td&gt;
&lt;td&gt;declare global types like &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;jest&lt;/code&gt;, &lt;code&gt;playwright&lt;/code&gt; explicitly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;baseUrl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;deprecated&lt;/td&gt;
&lt;td&gt;slated for removal in 7&lt;/td&gt;
&lt;td&gt;check whether &lt;code&gt;paths&lt;/code&gt; aliases lean on &lt;code&gt;baseUrl&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;moduleResolution: node10&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;deprecated&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nodenext&lt;/code&gt;/&lt;code&gt;bundler&lt;/code&gt; recommended&lt;/td&gt;
&lt;td&gt;for bundler apps, is &lt;code&gt;bundler&lt;/code&gt; the natural fit?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;target: es5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;up for cleanup&lt;/td&gt;
&lt;td&gt;removed&lt;/td&gt;
&lt;td&gt;is backward compat handled at the bundler layer rather than in TS?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Interestingly, when I dug through our code I found that some scripts already had &lt;code&gt;ignoreDeprecations: "6.0"&lt;/code&gt; baked into their compile options. A small sign that standing on the bridge doesn't mean "ready to go."&lt;/p&gt;

&lt;h2&gt;
  
  
  I ran it on our own monorepo
&lt;/h2&gt;

&lt;p&gt;Now the heart of it. Instead of re-citing the official benchmark table, I measured directly in our code.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Target&lt;/strong&gt;: &lt;code&gt;apps/frontend&lt;/code&gt; (Next.js App Router, &lt;strong&gt;728&lt;/strong&gt; &lt;code&gt;.ts&lt;/code&gt;/&lt;code&gt;.tsx&lt;/code&gt; files under &lt;code&gt;src&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tsconfig&lt;/strong&gt;: &lt;code&gt;noEmit: true&lt;/code&gt;, &lt;code&gt;incremental: true&lt;/code&gt;, &lt;code&gt;skipLibCheck: true&lt;/code&gt;, &lt;code&gt;strict: true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comparison&lt;/strong&gt;: &lt;code&gt;tsc&lt;/code&gt; 6.0.3 vs &lt;code&gt;tsgo&lt;/code&gt; (&lt;code&gt;@typescript/native-preview&lt;/code&gt;, version &lt;code&gt;7.0.0-dev.20260616.1&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Command&lt;/strong&gt;: &lt;code&gt;--noEmit&lt;/code&gt; on both sides (pure type-check time)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment&lt;/strong&gt;: Windows 11, Node v24.15.0&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;cold&lt;/em&gt; = first run after removing the incremental cache (&lt;code&gt;tsconfig.tsbuildinfo&lt;/code&gt;), &lt;em&gt;warm&lt;/em&gt; = a re-run with the cache present&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Installation is a one-liner. Anyone can reproduce the exact same thing in their own project.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; @typescript/native-preview@beta
&lt;span class="c"&gt;# the executable is named tsgo&lt;/span&gt;
npx tsgo &lt;span class="nt"&gt;-p&lt;/span&gt; apps/frontend/tsconfig.json &lt;span class="nt"&gt;--noEmit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Speed
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Compiler&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;cold&lt;/th&gt;
&lt;th&gt;warm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tsc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;6.0.3 (JS self-hosted)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6.7s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.9s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tsgo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7.0.0-dev (Go native)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.1s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.7s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;speedup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≈ 3.2×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≈ 2.7×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It got faster. When a cold 6.7s drops to 2.1s, that's a difference you feel on every PR in a CI environment with no cache. But it &lt;strong&gt;wasn't 10x.&lt;/strong&gt; And that's normal — I'll explain why below.&lt;/p&gt;

&lt;h3&gt;
  
  
  And the two compilers did not report the same errors
&lt;/h3&gt;

&lt;p&gt;What really made me sit up wasn't the speed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Compiler&lt;/th&gt;
&lt;th&gt;Error count&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tsc&lt;/code&gt; 6.0.3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;all stale references in Next.js-generated &lt;code&gt;.next/types/**&lt;/code&gt; (&lt;code&gt;TS2307&lt;/code&gt;) — not source bugs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tsgo&lt;/code&gt; 7.0-dev&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;same 3 above + 1 additional ↓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one line only &lt;code&gt;tsgo&lt;/code&gt; flagged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/features/abbreviations/hooks/useInfiniteAbbreviations.ts(82,9):
  error TS2769: No overload matches this call.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;useInfiniteQuery({ … initialData: transformedInitialData … })&lt;/code&gt;, overload resolution (the several call forms of one function) went different ways. &lt;code&gt;tsgo&lt;/code&gt; rejected it; &lt;code&gt;tsc&lt;/code&gt; 6.0.3 let it through. Same code, different verdict.&lt;/p&gt;

&lt;p&gt;Here I have to be honest. &lt;code&gt;tsgo&lt;/code&gt; is the 7.0 &lt;strong&gt;dev preview&lt;/strong&gt; and &lt;code&gt;tsc&lt;/code&gt; is the 6.0.3 &lt;strong&gt;stable release&lt;/strong&gt; — the type checker versions themselves differ. So whether this difference is (a) a check 7.0 intentionally strengthened, (b) a preview bug, or (c) a 6.0→7.0 difference in overload selection, &lt;strong&gt;I can't say for sure right now.&lt;/strong&gt; But the conclusion is clear: &lt;strong&gt;the preview's diagnostics don't yet match &lt;code&gt;tsc&lt;/code&gt; 100%.&lt;/strong&gt; And this isn't a drawback — it's the very first thing someone preparing for a migration needs to know.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does 10x come from, and why was it 3x for us
&lt;/h2&gt;

&lt;p&gt;The official "10x" isn't hype. Its source is clear. In the 2025 &lt;em&gt;A 10x Faster TypeScript&lt;/em&gt; announcement, Microsoft published a table measuring real open-source projects.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;LOC&lt;/th&gt;
&lt;th&gt;existing &lt;code&gt;tsc&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;native&lt;/th&gt;
&lt;th&gt;ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VS Code&lt;/td&gt;
&lt;td&gt;1,505,000&lt;/td&gt;
&lt;td&gt;77.8s&lt;/td&gt;
&lt;td&gt;7.5s&lt;/td&gt;
&lt;td&gt;10.4×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Playwright&lt;/td&gt;
&lt;td&gt;356,000&lt;/td&gt;
&lt;td&gt;11.1s&lt;/td&gt;
&lt;td&gt;1.1s&lt;/td&gt;
&lt;td&gt;10.1×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeORM&lt;/td&gt;
&lt;td&gt;270,000&lt;/td&gt;
&lt;td&gt;17.5s&lt;/td&gt;
&lt;td&gt;1.3s&lt;/td&gt;
&lt;td&gt;13.5×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;date-fns&lt;/td&gt;
&lt;td&gt;104,000&lt;/td&gt;
&lt;td&gt;6.5s&lt;/td&gt;
&lt;td&gt;0.7s&lt;/td&gt;
&lt;td&gt;9.5×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can see what they have in common. They're all &lt;strong&gt;hundreds of thousands to a million lines&lt;/strong&gt; — code where type checking is genuinely heavy. By that standard our &lt;code&gt;apps/frontend&lt;/code&gt; is on the small side, and on top of that it skips library type checking via &lt;code&gt;skipLibCheck&lt;/code&gt;, so &lt;code&gt;tsc&lt;/code&gt;'s original cost was already low. The conditions for a smaller multiplier are roughly these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;when the project is small, or more bound by file reading (I/O)&lt;/li&gt;
&lt;li&gt;when it's already optimized with &lt;code&gt;skipLibCheck&lt;/code&gt; and explicit &lt;code&gt;types&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;when the CI machine has few cores, so &lt;code&gt;tsgo&lt;/code&gt;'s parallelism is less exercised&lt;/li&gt;
&lt;li&gt;the fact that &lt;code&gt;tsgo --noEmit&lt;/code&gt; is only a type check and doesn't replace the &lt;em&gt;entire&lt;/em&gt; &lt;code&gt;next build&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So there are two sentences this post has to keep separate: "&lt;strong&gt;There is an official 10x&lt;/strong&gt;" (a fact with evidence) and "&lt;strong&gt;we got 10x in our code&lt;/strong&gt;" (a claim you're not allowed to make until you measure it yourself). Our honest answer is ~3x. It may look small, but in a cacheless CI, 6.7s becoming 2.1s is a clear gain the more it repeats.&lt;/p&gt;

&lt;p&gt;The speed comes from two things. One is the efficiency of Go native itself; the other is &lt;strong&gt;parallelization&lt;/strong&gt;. The 7.0 Beta runs parsing, type checking, and emit in parallel, tuning the number of type-checking workers with &lt;code&gt;--checkers&lt;/code&gt; and parallel project builds with &lt;code&gt;--builders&lt;/code&gt; (there's also &lt;code&gt;--singleThreaded&lt;/code&gt; for debugging). It's like opening several checkout lanes. But type checking isn't "ring up this customer's items and you're done" — it's entangled with other files, libraries, and generic inference, so parallelization isn't simply splitting files N ways; it's the problem of &lt;strong&gt;dividing the work while controlling shared knowledge and ordering dependencies&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What tsgo doesn't give you yet
&lt;/h2&gt;

&lt;p&gt;Don't take the word preview lightly. Looking at the current support table for &lt;code&gt;microsoft/typescript-go&lt;/code&gt;, parsing, type checking, build mode, and incremental builds are mostly marked "Done," but there are still blanks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;watch mode&lt;/strong&gt;: still a prototype. It can watch for file changes, but incremental re-checking isn't optimized.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compiler API&lt;/strong&gt;: &lt;strong&gt;not ready.&lt;/strong&gt; A stable API for calling the compiler's internals from code is signaled for 7.1 or later at the earliest. This is the core risk for the ecosystem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JS/JSDoc&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;.d.ts&lt;/code&gt; emit from &lt;code&gt;.js&lt;/code&gt;&lt;/strong&gt;: there are areas where behavior intentionally differs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Compiler API in particular is a quiet landmine. The following tools read TypeScript's internal API directly, so they don't automatically come along just because you switch &lt;code&gt;tsc&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Why it's risky&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;typescript-eslint&lt;/code&gt; (type-aware lint)&lt;/td&gt;
&lt;td&gt;reads the TypeScript API for type information&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ts-node&lt;/code&gt;, &lt;code&gt;ts-jest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;import the &lt;code&gt;typescript&lt;/code&gt; package at runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;custom transformers (&lt;code&gt;ts-patch&lt;/code&gt;, etc.)&lt;/td&gt;
&lt;td&gt;depend on the existing AST/transform API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;framework plugins like Vue/Angular&lt;/td&gt;
&lt;td&gt;use the language service plugin / compiler API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Microsoft acknowledges this too, providing separate compatibility packages (&lt;code&gt;@typescript/typescript6&lt;/code&gt;, &lt;code&gt;tsc6&lt;/code&gt;) for existing API consumers. In other words, they've officially admitted that "the compiler CLI gets faster, but the ecosystem that leans on the API needs a separate path."&lt;/p&gt;

&lt;p&gt;And not every fast tool does the same job. Let me lay out the boundaries with esbuild, SWC, and Biome — the ones people commonly confuse here.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Type checking&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;th&gt;Suited for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tsc&lt;/code&gt; 6.x&lt;/td&gt;
&lt;td&gt;full TypeScript type checking&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;current production baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tsgo&lt;/code&gt; / TS7&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;aims for&lt;/em&gt; full type checking&lt;/td&gt;
&lt;td&gt;~10x on large code&lt;/td&gt;
&lt;td&gt;fast type checking, validating the future transition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;esbuild&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;none&lt;/strong&gt; (treats types like comments)&lt;/td&gt;
&lt;td&gt;very fast&lt;/td&gt;
&lt;td&gt;bundling/transpiling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SWC&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;none&lt;/strong&gt; (per-file transform)&lt;/td&gt;
&lt;td&gt;very fast&lt;/td&gt;
&lt;td&gt;fast transforms in build pipelines like Next&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Biome&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;none&lt;/strong&gt; (formatter/linter)&lt;/td&gt;
&lt;td&gt;very fast&lt;/td&gt;
&lt;td&gt;partial replacement for Prettier/ESLint&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key distinction is this. esbuild, SWC, and Biome can speed up the TypeScript "development experience," but they are &lt;strong&gt;not type checkers that judge the whole type system the way &lt;code&gt;tsc&lt;/code&gt; and &lt;code&gt;tsgo&lt;/code&gt; do.&lt;/strong&gt; The moment you believe a fast transpiler stands in for type checking, bugs leak out. To &lt;em&gt;actually trust&lt;/em&gt; your types, you ultimately need &lt;code&gt;tsc&lt;/code&gt; or &lt;code&gt;tsgo&lt;/code&gt;. This question of "how far you can tame nondeterminism with tooling" is one I touched once on the Python side in &lt;a href="https://var.gg/ko/blog/pydantic-ai-typed-agents" rel="noopener noreferrer"&gt;a post on type-safe agents&lt;/a&gt; — different language, same grain of worry.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what to do now
&lt;/h2&gt;

&lt;p&gt;Putting it together, the right answer in June 2026 isn't "ditch &lt;code&gt;tsc&lt;/code&gt; and switch to &lt;code&gt;tsgo&lt;/code&gt;" but "&lt;strong&gt;run the two side by side&lt;/strong&gt;." Here's the decision flow as a diagram.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📊 The diagram for this section renders in the &lt;a href="https://var.gg/en/blog/typescript-native-tsgo" rel="noopener noreferrer"&gt;original article on var.gg →&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In words, here it is.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;First&lt;/strong&gt; clean up the warnings you hid with &lt;code&gt;ignoreDeprecations&lt;/code&gt; and the deprecated options. The list you hid is the list of errors in 7.0.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;@typescript/native-preview@beta&lt;/code&gt; as a devDependency.&lt;/li&gt;
&lt;li&gt;Measure &lt;code&gt;tsc --noEmit&lt;/code&gt; and &lt;code&gt;tsgo --noEmit&lt;/code&gt; repeatedly, 7+ times. Look at the &lt;strong&gt;diagnostics diff before&lt;/strong&gt; the average time.&lt;/li&gt;
&lt;li&gt;If the diff is 0, add &lt;code&gt;tsgo&lt;/code&gt; as a &lt;strong&gt;non-blocking CI job&lt;/strong&gt; to keep collecting speed and diagnostics. It's safe because a failure won't block the build.&lt;/li&gt;
&lt;li&gt;Until the 7.0 stable release and API stabilization, keep &lt;code&gt;typescript-eslint&lt;/code&gt;, the test runner, and custom transformers on the existing TS6 API path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We finished steps 1–3 with this round of measurement, and because the diagnostics diff wasn't 0 (the &lt;code&gt;TS2769&lt;/code&gt; above), we didn't jump straight to step 4 — we're looking into the cause of that one line first. This doesn't mean "the slower tool is smarter" — it's just the obvious procedure of &lt;strong&gt;confirming you get the same answer before switching to a faster tool&lt;/strong&gt;. The habit of adding one more layer of verification in an era when tools are changing also chimes with what I said in &lt;a href="https://var.gg/en/blog/git-tools-in-the-agent-era" rel="noopener noreferrer"&gt;a post on what git tools should become in the agent era&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion — not a swap, but being ready to measure for yourself
&lt;/h2&gt;

&lt;p&gt;The TypeScript 7 native compiler is an uncommon kind of event. It's a transplant at the heart of the ecosystem — a language moving a compiler that had been written in itself over to another language, &lt;strong&gt;straining not to break compatibility&lt;/strong&gt;. The speed improvement is no exaggeration even going by the official numbers alone, and there's a good chance there's a real gain for a TS monorepo like ours too.&lt;/p&gt;

&lt;p&gt;But wholesale-replacing the &lt;code&gt;typescript&lt;/code&gt; package right now and pushing even the API-leaning tools across all at once is premature. The most balanced stance is this — &lt;strong&gt;on top of TS 6.0.3, clean up the deprecated options, run &lt;code&gt;tsgo --noEmit&lt;/code&gt; side by side, and measure diagnostic equivalence and speed for yourself.&lt;/strong&gt; What that one round of measurement on our monorepo told us was two things: the honest number "about 3x at our scale," and the more valuable warning that "the preview doesn't give the same errors yet."&lt;/p&gt;

&lt;p&gt;Being fast isn't the finish line but the starting line. First confirm you get the same answer, then enjoy the speed you've gained. And that confirmation is something anyone can start on their own code today, with the single line &lt;code&gt;npx tsgo --noEmit&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;References: &lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0-beta/" rel="noopener noreferrer"&gt;TypeScript 7.0 Beta official announcement&lt;/a&gt; · &lt;a href="https://devblogs.microsoft.com/typescript/typescript-native-port/" rel="noopener noreferrer"&gt;A 10x Faster TypeScript&lt;/a&gt; · &lt;a href="https://github.com/microsoft/typescript-go" rel="noopener noreferrer"&gt;microsoft/typescript-go repository&lt;/a&gt; · benchmarks measured directly on var.gg &lt;code&gt;apps/frontend&lt;/code&gt; on 2026-06-16.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>Python 3.15's UTF-8 Default (PEP 686): I Verified the Silent Byte Changes on Windows</title>
      <dc:creator>curioustore</dc:creator>
      <pubDate>Mon, 29 Jun 2026 14:43:19 +0000</pubDate>
      <link>https://dev.to/curioustore_48788631d0e2e/python-315s-utf-8-default-pep-686-i-verified-the-silent-byte-changes-on-windows-2l4p</link>
      <guid>https://dev.to/curioustore_48788631d0e2e/python-315s-utf-8-default-pep-686-i-verified-the-silent-byte-changes-on-windows-2l4p</guid>
      <description>&lt;p&gt;I ran the same Python script on two versions. Both print &lt;code&gt;문서&lt;/code&gt; to the screen, identically. But the moment I redirected that output to a file with &lt;code&gt;&amp;gt; out.bin&lt;/code&gt;, the bytes left on disk diverged. One side wrote 4 bytes, the other 6.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python 3.14.6:  b9 ae bc ad
python 3.15.0b2: eb ac b8 ec 84 9c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a single line of code changed. I only bumped the interpreter version, yet the same characters &lt;code&gt;문서&lt;/code&gt; get written to disk as different bytes. This is the defining scene of the change coming in &lt;strong&gt;Python 3.15&lt;/strong&gt;, which ships its final release on October 1, 2026. The contract isn't the characters you see on screen — it's the bytes that actually get stored.&lt;/p&gt;

&lt;p&gt;This article is a hands-on record of touching that change directly. On a Korean-language Windows environment, I pulled down two real builds, Python 3.14.6 and 3.15.0b2, and ran the same code A/B to verify — byte by byte — what changes and what doesn't, and where the silent breakage that's more dangerous than an outright error shows up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!NOTE]&lt;br&gt;
Every measurement in this article was obtained by the author personally running two of python.org's embedded (portable) builds — &lt;code&gt;python-3.14.6-embed-amd64&lt;/code&gt; and &lt;code&gt;python-3.15.0b2-embed-amd64&lt;/code&gt; — under a Windows 11 Korean locale. 3.15.0b2 is the second beta, released on June 2, 2026; the feature is fully implemented, but this is not yet a production-recommended version.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Before we begin: encoding as a "translation dictionary"
&lt;/h2&gt;

&lt;p&gt;Before the main discussion, let me plainly define a few terms you'll need to follow this article all the way through. Readers who already know them can skip ahead.&lt;/p&gt;

&lt;p&gt;Computers don't understand characters. What gets stored on disk is, ultimately, a sequence of numbers (bytes). So we need an agreed-upon rule for which character gets written as which bytes, and that rule is called an &lt;strong&gt;encoding&lt;/strong&gt;. Think of it as a translation dictionary that converts back and forth between characters and bytes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UTF-8&lt;/strong&gt; — the de facto standard encoding of today's internet and modern text files, representing every character in the world in 1–4 bytes. A single Hangul character is usually 3 bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CP949&lt;/strong&gt; — the Korean code page (the Windows-style character table) Microsoft uses. A single Hangul character is 2 bytes. Old Windows Korean-language programs, &lt;code&gt;.txt&lt;/code&gt; files saved from Notepad, Excel CSVs, and the like commonly use this encoding. (It has the aliases &lt;code&gt;ms949&lt;/code&gt; and &lt;code&gt;uhc&lt;/code&gt;, and while it overlaps heavily with its cousin &lt;code&gt;euc-kr&lt;/code&gt;, the two are not the same standard.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locale&lt;/strong&gt; — the operating system's bundle of regional settings (language, country, date format, and so on). Traditionally, the locale also decided the default for "what to use when no encoding is specified." The default code page on Korean Windows is usually &lt;strong&gt;949&lt;/strong&gt;, i.e. CP949.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UTF-8 mode&lt;/strong&gt; — a runtime switch decided when Python starts. When it's on, Python uses UTF-8 instead of the locale for several defaults. PEP 686 is exactly what flips this switch's default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I had to state the crux in one sentence, it's this.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Python 3.15 does not convert your existing CP949 files to UTF-8. &lt;strong&gt;It only swaps the default translation dictionary that Python picks on your behalf when you don't specify an encoding — from "regional convention (CP949)" to "UTF-8."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When a package has no label describing its contents, the recipient has to guess what's inside by convention. Code that omits &lt;code&gt;encoding=&lt;/code&gt; is like a box with that label torn off. PEP 686 is a policy that changes the default convention to "if an unlabeled box arrives, assume UTF-8." The data inside the box itself doesn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exactly what changes — the defaults I measured directly
&lt;/h2&gt;

&lt;p&gt;First I launched both interpreters with no options and printed out the defaults. This is an environment where the Korean ACP (ANSI code page) is 949.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Python 3.14.6&lt;/th&gt;
&lt;th&gt;Python 3.15.0b2&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sys.flags.utf8_mode&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;← the master switch flips&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;open(path)&lt;/code&gt; default encoding&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;cp949&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;utf-8&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;← the core change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;sys.stdout.encoding&lt;/code&gt; (file/pipe)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;cp949&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;utf-8&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;← changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;locale.getpreferredencoding(False)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cp949&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utf-8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;← changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;locale.getencoding()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cp949&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cp949&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sys.getfilesystemencoding()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utf-8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;utf-8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;already&lt;/strong&gt; UTF-8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;encoding="locale"&lt;/code&gt; explicit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cp949&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cp949&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;encoding="utf-8"&lt;/code&gt; / &lt;code&gt;"cp949"&lt;/code&gt; explicit&lt;/td&gt;
&lt;td&gt;as-is&lt;/td&gt;
&lt;td&gt;as-is&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Let me start with the point that's most often misunderstood. &lt;strong&gt;The filesystem encoding (how file names and paths are handed to the OS) is already UTF-8 in both versions.&lt;/strong&gt; On Windows it has been ever since PEP 529 in Python 3.6. In other words, the ability to find a &lt;em&gt;file name&lt;/em&gt; like &lt;code&gt;C:\자료\한글.txt&lt;/code&gt; does not change this time. What changes is the default dictionary used when moving the &lt;em&gt;contents&lt;/em&gt; of that file between bytes and strings.&lt;/p&gt;

&lt;p&gt;These are two completely separate questions.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can you locate a file named &lt;code&gt;C:\자료\한글.txt&lt;/code&gt;? → path encoding (unchanged)&lt;/li&gt;
&lt;li&gt;Do you read that file's &lt;em&gt;contents&lt;/em&gt; as CP949 or as UTF-8? → text I/O encoding (changed this time)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Same program, different bytes
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;open(p, "w").write("한글 데이터: 매출 1234원")&lt;/code&gt; — I ran this write, with no encoding specified, on both versions and compared the bytes left on disk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3.14.6 (cp949):  24 bytes  c7 d1 b1 db 20 ... bf f8
3.15.0b2 (utf-8): 32 bytes  ed 95 9c ea b8 80 20 ... ec 9b 90
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The write &lt;strong&gt;succeeds without error on both sides.&lt;/strong&gt; I only bumped the interpreter, yet the output file's bytes silently change. The breakage surfaces not here, but later, in &lt;em&gt;whoever else reads that file&lt;/em&gt; — an Excel that expects CP949, an older Notepad, the neighboring team's legacy script.&lt;/p&gt;

&lt;h3&gt;
  
  
  Distinguishing the three layers
&lt;/h3&gt;

&lt;p&gt;Why does the "screen is fine but only the file changed" situation arise? Because Windows text I/O is actually split across three different boundaries.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;📊 The diagram for this section renders in the &lt;a href="https://var.gg/en/blog/python-315-utf8-default" rel="noopener noreferrer"&gt;original article on var.gg →&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A directly attached modern Windows console (Windows Terminal and the like) has used the Unicode API since PEP 528, so Hangul already prints fine even on 3.14. So if you see &lt;code&gt;print("한글")&lt;/code&gt; come out cleanly on screen and conclude "the default encoding must be the same," you'd be wrong. &lt;strong&gt;When you speak to the screen, a translator was already standing by; what changed this time is the envelope spec used when you mail a letter to a file or a pipe.&lt;/strong&gt; The &lt;code&gt;문서&lt;/code&gt; redirect experiment at the very top of this article — where the screen (or pipe) and the disk bytes diverged — happened for exactly this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why now — nine years in the making
&lt;/h2&gt;

&lt;p&gt;PEP 686 isn't a brand-new feature that popped up out of nowhere. It's a carefully pre-announced compatibility change that flips &lt;em&gt;only the default&lt;/em&gt; of a UTF-8 mode that has been available for years already.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Point in time&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python 3.6&lt;/td&gt;
&lt;td&gt;PEP 528·529&lt;/td&gt;
&lt;td&gt;Modernized the Windows console and filesystem paths around UTF-8 (already complete)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python 3.7&lt;/td&gt;
&lt;td&gt;PEP 540&lt;/td&gt;
&lt;td&gt;Introduced &lt;strong&gt;the switch called UTF-8 mode&lt;/strong&gt; — but default OFF, activated manually with &lt;code&gt;-X utf8&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python 3.10&lt;/td&gt;
&lt;td&gt;PEP 597&lt;/td&gt;
&lt;td&gt;Introduced &lt;code&gt;EncodingWarning&lt;/code&gt; and &lt;code&gt;encoding="locale"&lt;/code&gt; — tools to &lt;em&gt;find&lt;/em&gt; where implicit encoding is used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python 3.11&lt;/td&gt;
&lt;td&gt;preparatory work&lt;/td&gt;
&lt;td&gt;Corrected behavior so that even in UTF-8 mode, &lt;code&gt;encoding="locale"&lt;/code&gt; uses the real locale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python 3.15&lt;/td&gt;
&lt;td&gt;PEP 686&lt;/td&gt;
&lt;td&gt;Flips the &lt;strong&gt;default of that switch to ON&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The interesting part is the timeline itself. PEP 686 was first discussed targeting Python 3.13, but the Steering Council asked for 3.15 to allow a longer preparation period, and it was approved on that condition in June 2022. It was deliberately brought in slowly, as a change whose compatibility ripple effects were well understood.&lt;/p&gt;

&lt;p&gt;Whether it actually landed in 3.15.0b2 cross-checks three ways. (1) PEP 686's status is &lt;code&gt;Final&lt;/code&gt; and its target is &lt;code&gt;3.15&lt;/code&gt;. (2) The 3.15.0b2 release notes call it out as a major change. (3) The "What's New in Python 3.15" document states that I/O which omits the encoding — like &lt;code&gt;open()&lt;/code&gt; — uses UTF-8. And above all, the measurements in the table above are direct evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Silent breakage" — more dangerous than an error
&lt;/h2&gt;

&lt;p&gt;This is where the real trap of this change begins. It's easy to think, "So if I read a CP949 file with 3.15, it'll throw an error, right?" That's only half right. And the case where it &lt;em&gt;does&lt;/em&gt; error is actually the lucky one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Common Hangul fails loudly (the lucky case)
&lt;/h3&gt;

&lt;p&gt;Reading &lt;code&gt;한글&lt;/code&gt; saved as CP949 with 3.15's default &lt;code&gt;open()&lt;/code&gt; (UTF-8):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc7 in position 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conversely, reading &lt;code&gt;한글&lt;/code&gt; saved as UTF-8 with 3.14's default &lt;code&gt;open()&lt;/code&gt; (CP949) also throws &lt;code&gt;UnicodeDecodeError&lt;/code&gt;. The Hangul byte patterns of CP949 and UTF-8 are, for the most part, mutually "grammatically impossible combinations," so reading them the wrong way blows up immediately with an exception. It's loud. Loud is good. It lands in the logs, gets caught by &lt;code&gt;try/except&lt;/code&gt;, and is discovered before deployment.&lt;/p&gt;

&lt;h3&gt;
  
  
  But some bytes silently become different characters
&lt;/h3&gt;

&lt;p&gt;The problem is the range that is &lt;strong&gt;CP949 bytes and simultaneously valid UTF-8.&lt;/strong&gt; Such bytes throw no error read either way. They just become a &lt;em&gt;different character&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I counted by brute force, and among 2-byte sequences there were &lt;strong&gt;1027&lt;/strong&gt; cases that decode "without error in either codec, yet differently from each other." I reproduced two representative examples by actually reading them from files.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Disk bytes&lt;/th&gt;
&lt;th&gt;3.14.6 default (CP949) read&lt;/th&gt;
&lt;th&gt;3.15.0b2 default (UTF-8) read&lt;/th&gt;
&lt;th&gt;Error?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;c2 af&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'짱'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'¯'&lt;/code&gt; (macron symbol)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No error in either&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eb ac b8 ec 84 9c&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'臾몄꽌'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;'문서'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No error in either&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same file, same single line of &lt;code&gt;open(p).read()&lt;/code&gt;. Only the interpreter differs, yet the resulting string is completely different — and there's &lt;strong&gt;no exception anywhere.&lt;/strong&gt; No log, no traceback. The wrong data simply loads into memory as-is and gets re-saved or re-transmitted as-is. This is why it's more dangerous than &lt;code&gt;UnicodeDecodeError&lt;/code&gt;: an error can be caught, but silent mojibake — a state where the bytes were successfully interpreted but the characters are garbled — gives you not even a clue to catch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!WARNING]&lt;br&gt;
The default error handling for &lt;code&gt;open()&lt;/code&gt; is &lt;code&gt;strict&lt;/code&gt; in both versions. But what &lt;code&gt;strict&lt;/code&gt; checks is only "is this byte &lt;em&gt;valid&lt;/em&gt; under this encoding's grammar," not "are these the &lt;em&gt;right&lt;/em&gt; characters that were originally intended." The &lt;code&gt;c2 af&lt;/code&gt; → &lt;code&gt;¯&lt;/code&gt; above is perfectly valid UTF-8 grammatically, so &lt;code&gt;strict&lt;/code&gt; lets it pass too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where it's costlier and surfaces later: writes and appends
&lt;/h2&gt;

&lt;p&gt;The silent breakage on reads is scary, but the costlier accident in a migration actually happens with &lt;strong&gt;writes and appends&lt;/strong&gt; — because the damage surfaces much later.&lt;/p&gt;

&lt;p&gt;Suppose 3.15 appends one line, with its default (UTF-8), to a log file that had already been accumulating as CP949. Here's what I reproduced directly.&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="c1"&gt;# 기존 CP949 로그에 3.15 기본 open(..., "a")로 append
&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mixed.log&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;wb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;기존=한글&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cp949&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mixed.log&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;a&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;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="c1"&gt;# 3.15 기본 = UTF-8
&lt;/span&gt;    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;추가=문서&lt;/span&gt;&lt;span class="se"&gt;\n&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;Append doesn't inspect the existing file contents. It just tacks UTF-8 bytes onto the end. The result is a &lt;strong&gt;mixed-encoding file&lt;/strong&gt; that's CP949 in the front and UTF-8 in the back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw: b1 e2 c1 b8 3d c7 d1 b1 db 0a | ec b6 94 ea b0 80 3d eb ac b8 ec 84 9c 0d 0a
     └──────── CP949 ───────────┘   └──────────── UTF-8 ─────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you then try to read this file in its entirety:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cp949 full decode → UnicodeDecodeError
utf-8 full decode → UnicodeDecodeError
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;It has become a file that can't be read by any single encoding.&lt;/strong&gt; Yet at the moment of appending there was no error at all. The accident surfaces only weeks later, in log analysis, a backup restore, or a downstream ETL. By then both the original and the exact transition point are hazy.&lt;/p&gt;

&lt;p&gt;There's one more, similar trap. CP949 can't represent every Unicode character (emoji, certain symbols, and so on). So on 3.14, &lt;code&gt;f.write("상태🙂")&lt;/code&gt; could fail &lt;em&gt;early&lt;/em&gt; with a &lt;code&gt;UnicodeEncodeError&lt;/code&gt;. 3.15, being UTF-8, writes this quietly and successfully. On the surface it's an improvement, but if the consumer is still CP949, &lt;strong&gt;the old early warning has vanished and the compatibility problem has simply been pushed downstream.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To summarize the risk types:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Does &lt;code&gt;strict&lt;/code&gt; block it?&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wrong read, byte grammar also mismatches&lt;/td&gt;
&lt;td&gt;CP949 &lt;code&gt;한글&lt;/code&gt; as UTF-8&lt;/td&gt;
&lt;td&gt;Blocks (exception)&lt;/td&gt;
&lt;td&gt;Found immediately — lucky&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong read, valid under both grammars&lt;/td&gt;
&lt;td&gt;CP949 &lt;code&gt;짱&lt;/code&gt; as UTF-8&lt;/td&gt;
&lt;td&gt;Doesn't block&lt;/td&gt;
&lt;td&gt;Silent mojibake&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New file written in a different encoding&lt;/td&gt;
&lt;td&gt;3.15 generates a UTF-8 log&lt;/td&gt;
&lt;td&gt;Doesn't block&lt;/td&gt;
&lt;td&gt;Found at the consumption stage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTF-8 append to a CP949 file&lt;/td&gt;
&lt;td&gt;Mixed-encoding file&lt;/td&gt;
&lt;td&gt;Doesn't block&lt;/td&gt;
&lt;td&gt;Delayed failure, harder to recover&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-saving a wrongly-read string&lt;/td&gt;
&lt;td&gt;Permanently storing mojibake&lt;/td&gt;
&lt;td&gt;Doesn't block&lt;/td&gt;
&lt;td&gt;Hard to recover without the original&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  subprocess: when you capture the tool next door
&lt;/h2&gt;

&lt;p&gt;Silent change isn't confined to files. It's also in &lt;code&gt;subprocess(text=True)&lt;/code&gt;, which calls an external program and receives its output — because a fair number of Korean Windows console tools still emit CP949 bytes.&lt;/p&gt;

&lt;p&gt;I created a child process that outputs CP949 and had the parent capture its output with &lt;code&gt;text=True&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3.14.6 parent&lt;/strong&gt;: normal capture (&lt;code&gt;rc 0&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3.15.0b2 parent&lt;/strong&gt;: because the parent's pipe decoder is UTF-8, the moment it meets a CP949 byte the &lt;strong&gt;reader thread crashes with &lt;code&gt;UnicodeDecodeError&lt;/code&gt;.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;subprocess&lt;/code&gt; API itself wasn't redesigned in 3.15. The default codec of the &lt;code&gt;TextIOWrapper&lt;/code&gt; the parent wraps around the pipe follows &lt;code&gt;sys.flags.utf8_mode&lt;/code&gt;, and this is a chain reaction caused by that default flipping &lt;code&gt;0 → 1&lt;/code&gt;. Looking at the CPython source, the logic is identical across both versions.&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="c1"&gt;# Lib/subprocess.py (3.14.6 == 3.15.0b2)
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utf8_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getencoding&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;[!WARNING]&lt;br&gt;
A subtle trap: if you bump &lt;em&gt;both&lt;/em&gt; the parent and the child to 3.15, both switch to UTF-8 together and the test passes. But that success does not prove compatibility with actual CP949 legacy tools. So when verifying, you must make the child emit &lt;em&gt;fixed&lt;/em&gt; encoding bytes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The safety net is "explicit," not "automatic"
&lt;/h2&gt;

&lt;p&gt;So how do you prepare? The core conclusion first: &lt;strong&gt;&lt;code&gt;encoding=&lt;/code&gt; is not an optional argument but a protocol declaration.&lt;/strong&gt; In every place where bytes cross a process boundary — files, pipes, external tools — declaring the actual data contract is the only version- and OS-independent safety net.&lt;/p&gt;

&lt;h3&gt;
  
  
  The precise meaning of &lt;code&gt;encoding="locale"&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Here's a common misunderstanding. "To keep the old behavior, can't I just use &lt;code&gt;encoding="locale"&lt;/code&gt;?" Only partly right. &lt;code&gt;encoding="locale"&lt;/code&gt; (PEP 597, 3.10+) means &lt;strong&gt;"use this machine's locale encoding right now,"&lt;/strong&gt; not "use CP949." I confirmed directly that on 3.15 this expression still behaves as intended and read CP949 files correctly on a Korean machine. But run the same code on Japanese or English Windows, or on a UTF-8 ACP environment, and it changes again.&lt;/p&gt;

&lt;p&gt;The right expression differs per data contract.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data contract&lt;/th&gt;
&lt;th&gt;Recommended expression&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;This format is UTF-8 everywhere&lt;/td&gt;
&lt;td&gt;&lt;code&gt;encoding="utf-8"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;This legacy format is CP949 everywhere&lt;/td&gt;
&lt;td&gt;&lt;code&gt;encoding="cp949"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The file is designed to follow the &lt;em&gt;current user locale&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;encoding="locale"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External data of unknown encoding&lt;/td&gt;
&lt;td&gt;Don't auto-guess — check the metadata/protocol, or receive it as binary and validate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're dealing with a fixed CP949 format, &lt;code&gt;encoding="cp949"&lt;/code&gt; is a stronger contract than &lt;code&gt;"locale"&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Toggles and precedence
&lt;/h3&gt;

&lt;p&gt;The default changed, but the option didn't disappear. Here's the precedence I confirmed directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-X utf8 / -X utf8=0   (command line, highest priority)
   &amp;gt;  PYTHONUTF8=1 / =0   (environment variable)
   &amp;gt;  version default  (3.14 Windows = 0, 3.15 = 1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;On 3.15, &lt;code&gt;PYTHONUTF8=0&lt;/code&gt; or &lt;code&gt;-X utf8=0&lt;/code&gt; → immediately rolls back to the old CP949 behavior (reconfirmed &lt;code&gt;utf8_mode 0&lt;/code&gt;, &lt;code&gt;getpreferredencoding cp949&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;On 3.14, &lt;code&gt;PYTHONUTF8=1&lt;/code&gt; or &lt;code&gt;-X utf8&lt;/code&gt; → rehearse the 3.15 behavior in advance. &lt;strong&gt;Without a separate 3.15 install&lt;/strong&gt;, you can pre-check whether your current codebase breaks under UTF-8 mode.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PYTHONIOENCODING&lt;/code&gt; applies with priority only to the standard streams; it &lt;strong&gt;cannot change the defaults of ordinary &lt;code&gt;open()&lt;/code&gt; or subprocess pipes.&lt;/strong&gt; Don't overestimate its scope.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Migration priorities
&lt;/h3&gt;

&lt;p&gt;This is the order I'd recommend, based on the behavior I verified directly.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;For each file and pipe, confirm what encoding the actual producer and consumer use.&lt;/strong&gt; (First, and most often skipped.)&lt;/li&gt;
&lt;li&gt;Pin the formats we newly create with &lt;code&gt;encoding="utf-8"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pin actual CP949 contracts with &lt;code&gt;encoding="cp949"&lt;/code&gt;, then draw up a separate migration plan.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;encoding="locale"&lt;/code&gt; &lt;em&gt;only when&lt;/em&gt; following the user locale is a genuine requirement.&lt;/li&gt;
&lt;li&gt;Audit where implicit encoding is used with &lt;code&gt;-X warn_default_encoding&lt;/code&gt; (or &lt;code&gt;-W error::EncodingWarning&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A system-wide &lt;code&gt;PYTHONUTF8=0&lt;/code&gt; is not a root fix — keep it only as a last resort for dodging an outage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Configuration files, logs, caches, CSV exports, and external CLI calls in particular are more easily missed in audits than the main code. And if a third-party library internally calls &lt;code&gt;open(config)&lt;/code&gt; or &lt;code&gt;subprocess(text=True)&lt;/code&gt; without an encoding, you're affected even if your own code is clean. Here too, the top priority is finding the actual execution path with &lt;code&gt;EncodingWarning&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing — the direction is right, but the lesson is the opposite
&lt;/h2&gt;

&lt;p&gt;The direction of PEP 686 itself is sound. UTF-8 is already the de facto standard of the internet and modern text, and it narrows the long-standing asymmetry where an &lt;code&gt;open("README.md")&lt;/code&gt; that ran fine on Linux broke only on Windows. For a cross-platform project starting fresh, it's clearly less painful.&lt;/p&gt;

&lt;p&gt;But the compatibility cost is much wider than "reading a CP949 file throws an error." Compressing the conclusions I reached firsthand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Some CP949 bytes are also valid UTF-8 and &lt;strong&gt;silently become different characters&lt;/strong&gt; (&lt;code&gt;짱&lt;/code&gt; → &lt;code&gt;¯&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The bytes of new logs and export files change to UTF-8 &lt;strong&gt;without error.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Appending to an existing CP949 file produces a mixed file that's &lt;strong&gt;readable by no single codec.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;If both parent and child move up to 3.15 together, subprocess tests yield a &lt;strong&gt;false positive.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Because the console and filesystem are already UTF-8, a simple on-screen test &lt;strong&gt;misses&lt;/strong&gt; the change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the practical lesson of this change is &lt;strong&gt;not&lt;/strong&gt; "you can now omit &lt;code&gt;encoding=&lt;/code&gt;." Quite the opposite.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Python 3.15 made the implicit default more reasonable. But for long-term archival files, logs, pipes, and external-program protocols, &lt;strong&gt;explicit encoding is the only real safety net.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Characters showing up fine on screen and the correct bytes being written to disk are different problems. 3.15 reminds us — quietly, but unmistakably — that the two are not the same.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article follows the same methodology — firsthand verification that pulls down two versions directly and contrasts their behavioral differences in bytes and measurements — and is a sibling to &lt;a href="https://var.gg/en/blog/typescript-native-tsgo" rel="noopener noreferrer"&gt;the direct TypeScript native compiler (tsgo) benchmark&lt;/a&gt; and &lt;a href="https://var.gg/ko/blog/postgresql-19-deep-dive" rel="noopener noreferrer"&gt;the PostgreSQL 19 deep-dive verification&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  References (official docs)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://peps.python.org/pep-0686/" rel="noopener noreferrer"&gt;PEP 686 – Make UTF-8 mode default&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.python.org/3.15/whatsnew/3.15.html" rel="noopener noreferrer"&gt;What's New in Python 3.15&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://peps.python.org/pep-0540/" rel="noopener noreferrer"&gt;PEP 540 – Add a new UTF-8 Mode&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://peps.python.org/pep-0597/" rel="noopener noreferrer"&gt;PEP 597 – Add optional EncodingWarning&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://peps.python.org/pep-0529/" rel="noopener noreferrer"&gt;PEP 529 – Change Windows filesystem encoding to UTF-8&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.python.org/3.14/using/windows.html" rel="noopener noreferrer"&gt;Using Python on Windows (3.14 docs)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>windows</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Did Deno 2.8 Swallow the npm Toolchain? I Ran install, ci, audit, and pack Myself</title>
      <dc:creator>curioustore</dc:creator>
      <pubDate>Mon, 29 Jun 2026 14:41:16 +0000</pubDate>
      <link>https://dev.to/curioustore_48788631d0e2e/did-deno-28-swallow-the-npm-toolchain-i-ran-install-ci-audit-and-pack-myself-53pg</link>
      <guid>https://dev.to/curioustore_48788631d0e2e/did-deno-28-swallow-the-npm-toolchain-i-ran-install-ci-audit-and-pack-myself-53pg</guid>
      <description>&lt;p&gt;Same project, same dependencies. And yet, when I asked both tools "how many vulnerabilities do you have?", they answered differently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npm audit
2 vulnerabilities (1 high, 1 critical)

$ deno audit
Found 7 vulnerabilities
Severity: 0 low, 3 moderate, 3 high, 1 critical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I didn't change a single character of &lt;code&gt;package.json&lt;/code&gt;. I deliberately pinned just two packages — &lt;code&gt;lodash@4.17.15&lt;/code&gt; and &lt;code&gt;minimist@1.2.5&lt;/code&gt; — to old versions, then ran &lt;code&gt;npm install&lt;/code&gt; and &lt;code&gt;deno install&lt;/code&gt; separately. And yet npm says "2" while Deno says "7." Neither was lying — as we'll see later, the two were &lt;strong&gt;looking at the same database but counting in different ways.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This small discrepancy points precisely at the subject of this article. Deno has long claimed to "replace Node." Usually we hear that as the problem of swapping &lt;code&gt;node server.js&lt;/code&gt; for &lt;code&gt;deno run server.ts&lt;/code&gt;. But what we actually reach for several times a day in real work isn't the runtime — it's the &lt;strong&gt;package-manager routines&lt;/strong&gt;: you pull dependencies (&lt;code&gt;install&lt;/code&gt;), reproduce them identically in CI (&lt;code&gt;ci&lt;/code&gt;), scan for vulnerabilities (&lt;code&gt;audit&lt;/code&gt;), and build a distributable bundle (&lt;code&gt;pack&lt;/code&gt;). Deno 2.8 walked right into this territory.&lt;/p&gt;

&lt;p&gt;So I narrowed the question to this: &lt;strong&gt;leaving the runtime as Node, can you simply swap in the Deno CLI for &lt;code&gt;npm install&lt;/code&gt;, &lt;code&gt;npm ci&lt;/code&gt;, &lt;code&gt;npm audit&lt;/code&gt;, and &lt;code&gt;npm pack&lt;/code&gt; on the same &lt;code&gt;package.json&lt;/code&gt; project?&lt;/strong&gt; To see whether it actually works rather than taking the marketing copy at face value, I set up Windows 11 + Node v24.15 + Deno 2.8.0 and ran all five head-to-head with the same fixture.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!NOTE]&lt;br&gt;
This article is an &lt;strong&gt;experiment pinned to Deno 2.8.x&lt;/strong&gt;. By the time I was writing it up on 2026-06-25, Deno 2.9.0 had already shipped, and 2.9 added a feature that automatically seeds &lt;code&gt;deno.lock&lt;/code&gt; from &lt;code&gt;package-lock.json&lt;/code&gt;, &lt;code&gt;pnpm-lock.yaml&lt;/code&gt;, and the like. In other words, the initial lockfile migration behavior changes in 2.9. The numbers and behaviors below are all relative to 2.8.0.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  First, just five terms — let's start with "what these are"
&lt;/h2&gt;

&lt;p&gt;Before going deep, let me lay down only the minimal vocabulary you need to read this article, in plain terms. Readers who already know this can skip ahead without losing the thread.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Runtime&lt;/strong&gt;: the engine plus the bundle of standard APIs that actually executes JS/TS code. Node, Deno, and Bun fall here. Think of it as &lt;em&gt;different orchestras playing the same score (your code)&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Package manager&lt;/strong&gt;: the tool that downloads external libraries, pins their versions, and assembles &lt;code&gt;node_modules&lt;/code&gt;. npm, pnpm, Yarn, Bun — and now the Deno CLI — compete for this spot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;lockfile&lt;/strong&gt;: a file recording the exact version, download URL, and integrity hash of every package actually installed. For npm it's &lt;code&gt;package-lock.json&lt;/code&gt;; for Deno it's &lt;code&gt;deno.lock&lt;/code&gt;. It's &lt;em&gt;a receipt that says not "3 tomatoes" but "3 tomatoes shipped from Farm A on June 25"&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;frozen (reproducible) install&lt;/strong&gt;: an install mode that does not refresh the lockfile and simply fails if the lockfile and &lt;code&gt;package.json&lt;/code&gt; disagree. It's &lt;em&gt;cooking strictly to a sealed recipe and stopping if the ingredient list changes&lt;/em&gt;. It's the key device for preventing "but it worked on my laptop" in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;audit DB&lt;/strong&gt;: a database collecting the names and version ranges of vulnerable packages. The &lt;code&gt;audit&lt;/code&gt; command checks your lockfile against this list. It's &lt;em&gt;cross-referencing the food in your fridge against a recall list.&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now that the terms are in hand, let's walk through the five scenes I actually ran, one at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scene 1 — &lt;code&gt;install&lt;/code&gt;: same downloads, but faster, and stacked differently
&lt;/h2&gt;

&lt;p&gt;The first thing I measured was install time from a cold state. npm ran as usual; for Deno I emptied &lt;code&gt;DENO_DIR&lt;/code&gt; (the global cache location) to a new directory to make it genuinely cold before running.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;&lt;code&gt;npm install&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;deno install&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;cold install time (3 deps)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,744 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;559 ms&lt;/strong&gt; (~3.1x faster)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lockfile produced&lt;/td&gt;
&lt;td&gt;&lt;code&gt;package-lock.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;deno.lock&lt;/code&gt; (no package-lock generated)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Deno was 3x faster. But if you just parrot the commonly cited "Deno is 3.66x faster than npm" line, you'll get it wrong.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!WARNING]&lt;br&gt;
The Deno official blog's &lt;strong&gt;"3.66x faster" is not a figure relative to the npm CLI.&lt;/strong&gt; It's the improvement from &lt;strong&gt;Deno 2.7 (3,319ms) → Deno 2.8 (906ms)&lt;/strong&gt; when running a benchmark that downloads React, Vite, Babel, and ESLint on Linux with a fresh &lt;code&gt;DENO_DIR&lt;/code&gt;. In other words, it's "new Deno vs. old Deno." The ~3.1x I measured is a separate thing — &lt;strong&gt;Deno 2.8 vs. the npm CLI on Windows&lt;/strong&gt; with the same fixture — and on top of that, npm's global cache (&lt;code&gt;~/.npm&lt;/code&gt;) was already warm, yet cold Deno still won. A fully cold npm would have been slower. The honest reading is to take it as a "direction" rather than the multiple itself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;More interesting than the speed was &lt;strong&gt;how things get stacked&lt;/strong&gt;. When I looked at the top level of the &lt;code&gt;node_modules&lt;/code&gt; each tool produced, the structure differed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# npm: flat hoist — direct 3 + transitive 5 = all 8 at top level
node_modules/
  ansi-styles/  chalk/  color-convert/  color-name/
  has-flag/  lodash/  minimist/  supports-color/

# deno: junction isolation (pnpm style) — only the 3 "declared" at top level
node_modules/
  .deno/            &amp;lt;- the real thing lives here, isolated under .deno/&amp;lt;pkg&amp;gt;@&amp;lt;ver&amp;gt;/
  chalk    -&amp;gt; junction
  lodash   -&amp;gt; junction
  minimist -&amp;gt; junction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;npm pulls even transitive dependencies (packages I didn't write down directly but that chalk dragged in, like &lt;code&gt;has-flag&lt;/code&gt;) all the way up to the top level and lays them out flat. Deno exposes at the top level &lt;strong&gt;only the 3 I wrote directly&lt;/strong&gt; into &lt;code&gt;package.json&lt;/code&gt;, as junctions (Windows directory shortcuts), and hides the rest inside &lt;code&gt;.deno/&lt;/code&gt;. You could liken it to &lt;em&gt;npm spreading out the entire warehouse for every project, while Deno keeps a shared distribution warehouse and only sets up the display shelves&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This structural difference isn't an abstract matter of taste. It immediately splits how code behaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scene 2 — phantom dependency: the same code worked on one side and failed on the other
&lt;/h2&gt;

&lt;p&gt;I deliberately tried to &lt;code&gt;require&lt;/code&gt; &lt;code&gt;has-flag&lt;/code&gt; (a transitive dependency dragged in by chalk) even though it isn't written anywhere in &lt;code&gt;package.json&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// phantom.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;has-flag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;phantom require ok:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# node (flat node_modules)
$ node phantom.js
phantom require ok: function      &amp;lt;- success

# deno (junction isolation)
$ deno run -A phantom.js
error: Cannot find module 'has-flag'   &amp;lt;- failure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same file, a &lt;code&gt;node_modules&lt;/code&gt; folder of the same name — yet the results are exact opposites. Because npm's flattening lifts even undeclared packages to the top level, the &lt;code&gt;require&lt;/code&gt; happens to succeed — and this is the notorious &lt;strong&gt;phantom dependency&lt;/strong&gt;. Your code is secretly leaning on a library you never wrote into &lt;code&gt;package.json&lt;/code&gt;, and then one day chalk swaps out its dependencies and you get an inexplicable build failure. Deno's isolation structure makes &lt;em&gt;only what you declared visible&lt;/em&gt;, blocking this accidental dependency from the start.&lt;/p&gt;

&lt;p&gt;This isn't a simple "Deno is stricter" ranking — it's a signal that the &lt;strong&gt;behavior is different&lt;/strong&gt;. Some tools written assuming a flat layout can break under Deno's isolation structure (which is why Deno 2.8 also added a hoisted linker option that lays things out flat like npm). This "only what you declared is visible" philosophy runs along the same grain as &lt;a href="https://var.gg/ko/blog/uv-audit-supply-chain" rel="noopener noreferrer"&gt;the way uv bolted a security-inspector role onto the package install tool&lt;/a&gt; — and the next scene is exactly that audit story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scene 3 — &lt;code&gt;ci&lt;/code&gt;: both "stop if you break the seal," but their tone differs
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npm ci&lt;/code&gt; is a command for CI. If the lockfile is missing or disagrees with &lt;code&gt;package.json&lt;/code&gt;, it refuses to install; it deletes the existing &lt;code&gt;node_modules&lt;/code&gt; and then reproduces strictly from the lockfile. Deno 2.8's &lt;a href="https://docs.deno.com/runtime/reference/cli/ci/" rel="noopener noreferrer"&gt;&lt;code&gt;deno ci&lt;/code&gt;&lt;/a&gt; has nearly the same intent — the official docs describe it as roughly equivalent to &lt;code&gt;rm -rf node_modules &amp;amp;&amp;amp; deno install --frozen&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To check, I changed only the &lt;code&gt;lodash&lt;/code&gt; version in &lt;code&gt;package.json&lt;/code&gt; so it disagreed with the lockfile (deliberately not updating the lockfile) and ran both sides. Both &lt;strong&gt;failed correctly&lt;/strong&gt;. The failure is the feature — stopping when you try to buy an ingredient that isn't on the sealed receipt is the heart of reproducibility. The difference was the &lt;strong&gt;tone&lt;/strong&gt; of the refusal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# npm ci
npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and
npm error package-lock.json ... are in sync. Please update your lock file
npm error with `npm install` before continuing.
npm error Invalid: lock file's lodash@4.17.15 does not satisfy lodash@4.17.21

# deno ci
error: The lockfile is out of date. Run `deno install --frozen=false`,
       or rerun with `--frozen=false` to update it.
changes:
 5 | -    "npm:lodash@4.17.15": "4.17.15",
 5 | +    "npm:lodash@4.17.21": "4.17.21",
34 | -    "lodash@4.17.15": { ... }
35 | -      "integrity": "sha512-8xOcRHvCjno..."
35 | +      "integrity": "sha512-v2kDEe57lec..."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;npm wraps it up briefly: "they don't match, so refresh the lock with &lt;code&gt;npm install&lt;/code&gt;." Deno goes a step further and shows a &lt;strong&gt;precise diff that even includes the integrity hashes&lt;/strong&gt; — it spreads out, right there, which line of the lockfile should change and how. When you're tracking down "why did this break" in a CI log, that difference matters more than you'd expect. Standing two versions side by side and seeing which one gives the friendlier diagnostic is the same verification attitude I took when I &lt;a href="https://var.gg/ko/blog/typescript-native-tsgo" rel="noopener noreferrer"&gt;compared tsgo (the native TypeScript compiler) head-to-head against the old compiler&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scene 4 — &lt;code&gt;audit&lt;/code&gt;: the truth behind 2 vs. 7
&lt;/h2&gt;

&lt;p&gt;Now back to the puzzle from the opening. npm says "2," Deno says "7." Reading the output all the way through made the answer clear — &lt;strong&gt;the two were looking at the same database.&lt;/strong&gt; Both cited the same &lt;code&gt;GHSA-&lt;/code&gt; identifiers from the GitHub Advisory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# npm audit — counts by "package"
lodash  &amp;lt;=4.17.23   Severity: high   (6 advisories folded into 1 line)
minimist  1.0.0 - 1.2.5   Severity: critical
=&amp;gt; 2 vulnerabilities (1 high, 1 critical)

# deno audit — counts by "advisory," one by one
Command Injection in lodash ............ GHSA-35jh-r3h4-6jhm  high
Prototype Pollution in lodash .......... GHSA-p6mc-m468-83gw  high
ReDoS in lodash ........................ GHSA-29mw-wpgm-hmr9  moderate
... (6 lodash advisories expanded)
Prototype Pollution in minimist ........ GHSA-xvch-5gv4-984h  critical
=&amp;gt; Found 7 vulnerabilities (3 moderate, 3 high, 1 critical)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;npm counts "how many packages are vulnerable" (lodash 1 + minimist 1 = 2), while Deno counts "how many advisories were hit" (lodash 6 + minimist 1 = 7). They merely &lt;strong&gt;aggregate the same threat in different units&lt;/strong&gt; — neither is wrong. So when you compare the two tools' audit results, you should line up not the "counts" but the &lt;strong&gt;advisory IDs, vulnerable version ranges, and dependency paths&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!WARNING]&lt;br&gt;
It's safest not to assert exactly &lt;em&gt;which&lt;/em&gt; DB &lt;code&gt;deno audit&lt;/code&gt; uses. Even Deno's own docs phrase it differently — the 2.6 announcement post says "GitHub CVE database," the &lt;code&gt;deno audit&lt;/code&gt; CLI docs generally say "vulnerability databases" (+ a socket.dev option via &lt;code&gt;--socket&lt;/code&gt;), and the package-manager migration docs say "npm advisory database." &lt;strong&gt;An assertion like "Deno uses OSV" is not confirmed by official sources.&lt;/strong&gt; What I can say empirically from my experiment goes only as far as the fact that "both surfaced the same GHSA identifiers."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On top of that, even with the same &lt;code&gt;package.json&lt;/code&gt;, if npm and Deno installed using different lockfiles (&lt;code&gt;package-lock.json&lt;/code&gt; vs. &lt;code&gt;deno.lock&lt;/code&gt;), the dependency graph being inspected could be subtly different in the first place. In that case the difference in audit results isn't a "false positive" but the &lt;strong&gt;result of inspecting a different graph&lt;/strong&gt;. &lt;code&gt;deno audit --fix&lt;/code&gt;, like npm, automatically bumps vulnerable direct dependencies within the SemVer-compatible range, but conservatively leaves major upgrades and transitive dependencies untouched (&lt;a href="https://docs.deno.com/runtime/reference/cli/audit/" rel="noopener noreferrer"&gt;deno audit docs&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Scene 5 — &lt;code&gt;pack&lt;/code&gt;: this one actually wasn't the same kind of tool
&lt;/h2&gt;

&lt;p&gt;Here I ran into the biggest misconception. At first I tried to compare &lt;code&gt;npm pack&lt;/code&gt; and &lt;code&gt;deno pack&lt;/code&gt; side by side on the same &lt;code&gt;package.json&lt;/code&gt; project, but after reading the official docs I had to change my experiment design. The &lt;a href="https://docs.deno.com/runtime/reference/cli/pack/" rel="noopener noreferrer"&gt;&lt;code&gt;deno pack&lt;/code&gt; docs&lt;/a&gt; nail it down — &lt;strong&gt;&lt;code&gt;deno pack&lt;/code&gt; is not equivalent to &lt;code&gt;npm pack&lt;/code&gt;.&lt;/strong&gt; It doesn't even read your existing &lt;code&gt;package.json&lt;/code&gt;, and it doesn't honor &lt;code&gt;.npmignore&lt;/code&gt; or lifecycle scripts.&lt;/p&gt;

&lt;p&gt;So I ran the two &lt;em&gt;each according to its own role&lt;/em&gt;. &lt;code&gt;npm pack&lt;/code&gt; on a plain &lt;code&gt;package.json&lt;/code&gt; project, and &lt;code&gt;deno pack&lt;/code&gt; on a &lt;code&gt;deno.json&lt;/code&gt;-based TypeScript project.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# npm pack — compresses the source as-is (no transformation)
package/index.js
package/test.js
package/package.json        (4 files, 944B)

# deno pack — from a single mod.ts...
package/package.json   &amp;lt;- "synthesized" from deno.json metadata
package/mod.js         &amp;lt;- TS "transpiled" to JS
package/mod.d.ts       &amp;lt;- type declarations "generated"        (680B)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The results reveal the essence. &lt;code&gt;npm pack&lt;/code&gt; is a &lt;strong&gt;compressor&lt;/strong&gt; — it puts the files you currently have into a tarball as-is. &lt;code&gt;deno pack&lt;/code&gt; is a &lt;strong&gt;distribution transpiler&lt;/strong&gt; — it turns &lt;code&gt;.ts&lt;/code&gt; into &lt;code&gt;.js&lt;/code&gt;, generates &lt;code&gt;.d.ts&lt;/code&gt; type declarations, and synthesizes a &lt;code&gt;package.json&lt;/code&gt; from &lt;code&gt;deno.json&lt;/code&gt;, converting it into a bundle that npm consumers can understand. One "packages the npm package you already have," the other "ushers code from the Deno/JSR world through customs into the npm world."&lt;/p&gt;

&lt;p&gt;So the sentence &lt;em&gt;"&lt;code&gt;deno pack&lt;/code&gt; swallowed &lt;code&gt;npm pack&lt;/code&gt;"&lt;/em&gt; is wrong. More precisely: &lt;code&gt;deno pack&lt;/code&gt; didn't replace npm's packing — it's a &lt;strong&gt;different category of tool that automates the customs process of exporting a library written in Deno/JSR to npm.&lt;/strong&gt; The docs even honestly note the limits — &lt;code&gt;bin&lt;/code&gt; entries for CLIs, and native add-ons, are out of scope.&lt;/p&gt;

&lt;h3&gt;
  
  
  On the side — how far does runtime compatibility go?
&lt;/h3&gt;

&lt;p&gt;Separate from the package-manager discussion, I ran the same &lt;code&gt;index.js&lt;/code&gt; (code that loads Node built-ins &lt;code&gt;fs&lt;/code&gt;, &lt;code&gt;path&lt;/code&gt;, &lt;code&gt;crypto&lt;/code&gt;, and &lt;code&gt;os&lt;/code&gt; plus lodash, minimist, and chalk via CommonJS) under both &lt;code&gt;node&lt;/code&gt; and &lt;code&gt;deno run&lt;/code&gt;. The output was &lt;strong&gt;byte-for-byte identical&lt;/strong&gt; — the sha256 hash, the lodash results, and chalk's ANSI colors were all the same, with the only difference being &lt;code&gt;argv0&lt;/code&gt; (&lt;code&gt;node.exe&lt;/code&gt; vs. &lt;code&gt;deno.exe&lt;/code&gt;). Deno also passed all seven of the trickier built-ins (&lt;code&gt;child_process&lt;/code&gt;, &lt;code&gt;worker_threads&lt;/code&gt;, &lt;code&gt;cluster&lt;/code&gt;, &lt;code&gt;v8.serialize&lt;/code&gt;, &lt;code&gt;vm&lt;/code&gt;, &lt;code&gt;process.binding&lt;/code&gt;, &lt;code&gt;os.cpus&lt;/code&gt;) in a CommonJS context.&lt;/p&gt;

&lt;p&gt;The "76.4% Node compatibility" the Deno 2.8 blog touts roughly matches this experience, but you mustn't misread the number.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!NOTE]&lt;br&gt;
76.4% is relative to &lt;strong&gt;Node.js's own test suite&lt;/strong&gt; (3,405 of 4,457 passing), not the JS language standard (test262) and not a claim that "76% of real projects run." If the API or build tool &lt;em&gt;you&lt;/em&gt; use is in the remaining ~24%, that project breaks. Test pass rate and project compatibility are different axes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In fact I directly saw the boundary of compatibility in two places.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;require&lt;/code&gt; is context-sensitive.&lt;/strong&gt; For a bare &lt;code&gt;.js&lt;/code&gt; file outside a &lt;code&gt;package.json&lt;/code&gt; (&lt;code&gt;type: commonjs&lt;/code&gt;), Deno treated it as ESM and threw &lt;code&gt;require is not defined&lt;/code&gt;. Node is more lenient. It's a porting trap that's sensitive to file location and the &lt;code&gt;type&lt;/code&gt; field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lifecycle scripts don't run by default.&lt;/strong&gt; When I set a &lt;code&gt;postinstall&lt;/code&gt; at the root and installed, &lt;code&gt;npm install&lt;/code&gt; ran the script (creating &lt;code&gt;POSTINSTALL_RAN.txt&lt;/code&gt;) but &lt;strong&gt;&lt;code&gt;deno install&lt;/code&gt; did not.&lt;/strong&gt; This is double-edged — it's a &lt;strong&gt;security advantage&lt;/strong&gt; that blocks postinstall, a frequent vector for supply-chain attacks, by default, but at the same time it's a &lt;strong&gt;trap that breaks&lt;/strong&gt; packages relying on &lt;code&gt;node-gyp&lt;/code&gt; builds or postinstall (e.g., some native add-ons).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  At a glance — where in the npm toolchain did Deno 2.8 land?
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;📊 The decision-flow diagram for this section renders in the &lt;a href="https://var.gg/en/blog/deno-28-npm-toolchain" rel="noopener noreferrer"&gt;original article on var.gg →&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  An honest verdict table on replaceability
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Verdict&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;Does &lt;code&gt;deno install&lt;/code&gt; replace &lt;code&gt;npm install&lt;/code&gt;?&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Partially yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;It reads &lt;code&gt;package.json&lt;/code&gt;, pulls from the npm registry, and builds &lt;code&gt;node_modules&lt;/code&gt;. But it uses a separate &lt;code&gt;deno.lock&lt;/code&gt;, and as of 2.8 it neither reads nor writes &lt;code&gt;package-lock.json&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does &lt;code&gt;deno ci&lt;/code&gt; replace &lt;code&gt;npm ci&lt;/code&gt;?&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes, if you adopt &lt;code&gt;deno.lock&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Both fail on a lockfile mismatch, delete &lt;code&gt;node_modules&lt;/code&gt;, and do a frozen install. Only the reference lockfile differs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does &lt;code&gt;deno audit&lt;/code&gt; replace &lt;code&gt;npm audit&lt;/code&gt;?&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Detection is comparable, policy differs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;It surfaces the same GHSAs but differs in counting unit, autofix scope, and JSON/signature-verification features.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does &lt;code&gt;deno pack&lt;/code&gt; replace &lt;code&gt;npm pack&lt;/code&gt;?&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Mostly no (different category)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The official docs explicitly say it's "not equivalent." It's a Deno/JSR → npm converter.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does &lt;code&gt;deno run&lt;/code&gt; replace &lt;code&gt;node&lt;/code&gt;?&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Pure JS/npm is worth experimenting with, but not everything&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Claims 76.4% Node test pass rate. Native add-ons, postinstall, and layout-dependent tools are the boundary.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  So who else is being compared against npm?
&lt;/h2&gt;

&lt;p&gt;Deno isn't the only one eyeing npm's job. pnpm, Yarn, and Bun have long competed for the same spot.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Install structure&lt;/th&gt;
&lt;th&gt;CI reproducibility&lt;/th&gt;
&lt;th&gt;Built-in audit&lt;/th&gt;
&lt;th&gt;pack/publish&lt;/th&gt;
&lt;th&gt;One-line take&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;npm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;flat &lt;code&gt;node_modules&lt;/code&gt; (ecosystem baseline)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm ci&lt;/code&gt; (lock required)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;audit&lt;/code&gt; / &lt;code&gt;audit fix&lt;/code&gt; / &lt;code&gt;audit signatures&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm pack&lt;/code&gt; / &lt;code&gt;publish&lt;/code&gt; standard&lt;/td&gt;
&lt;td&gt;Can be slow, but it's the baseline. Strongest publish compatibility&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;pnpm&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;content-addressable + hardlink (disk savings)&lt;/td&gt;
&lt;td&gt;frozen-lockfile by default, &lt;code&gt;pnpm ci&lt;/code&gt; (11.x)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pnpm audit&lt;/code&gt;, signing supported&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pnpm pack&lt;/code&gt; / publish&lt;/td&gt;
&lt;td&gt;Strong for large monorepos and strict dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Yarn Berry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PnP by default (&lt;code&gt;.pnp.cjs&lt;/code&gt;, no &lt;code&gt;node_modules&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--immutable&lt;/code&gt; (CI default)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn npm audit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn npm publish&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Zero-install philosophy. Migrating existing projects needs care&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bun&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;fast manager + runtime (&lt;code&gt;bun.lock&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--frozen-lockfile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bun audit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bun pm pack&lt;/code&gt; (follows npm pack rules)&lt;/td&gt;
&lt;td&gt;Strong on speed and all-in-one. Full Node compat needs separate checking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Deno 2.8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;global cache + isolated/hoisted linker&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;deno ci&lt;/code&gt; (&lt;code&gt;deno.lock&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;deno audit&lt;/code&gt; / &lt;code&gt;--fix&lt;/code&gt; / &lt;code&gt;--socket&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;deno pack&lt;/code&gt; (JSR→npm conversion, npm pack ≠)&lt;/td&gt;
&lt;td&gt;install/ci/audit are worth experimenting with; pack is a different category&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  So when is it worth swapping in?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Worth trying&lt;/strong&gt;: small services with mostly pure JS/TS dependencies, no native add-ons or complex workspaces. When you want to keep the runtime on Node and just swap install and audit to Deno for speed. When you want to block postinstall by default for security. Authors who want to also distribute a Deno/JSR library to npm (this matches the original purpose of &lt;code&gt;deno pack&lt;/code&gt; exactly).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be cautious&lt;/strong&gt;: native add-on dependencies (which need a local &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;--allow-ffi&lt;/code&gt;, and approved scripts), complex bundler/plugin toolchains that assume a flat layout, large monorepos using &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt; (2.8 doesn't read this file), Yarn PnP projects, and organizations bound to the npm publish lifecycle or &lt;code&gt;npm audit signatures&lt;/code&gt; compliance.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest conclusion
&lt;/h2&gt;

&lt;p&gt;The opening 2 vs. 7 wasn't a sign that Deno was wrong or that npm was wrong, but that &lt;strong&gt;the two tools stand in the same territory and work by slightly different rules.&lt;/strong&gt; The five scenes of this article revealed those rules one by one.&lt;/p&gt;

&lt;p&gt;Deno 2.8 has not "fully replaced" npm. But for &lt;code&gt;install&lt;/code&gt;, &lt;code&gt;ci&lt;/code&gt;, and &lt;code&gt;audit&lt;/code&gt;, it has now reached &lt;strong&gt;a level where you can seriously consider swapping it in&lt;/strong&gt; on the same &lt;code&gt;package.json&lt;/code&gt; project — faster cold installs, an isolation layout that blocks phantom dependencies, friendlier lockfile diffs, and an audit that sees the same GHSAs. On the other hand, &lt;code&gt;pack&lt;/code&gt; is not a replacement for &lt;code&gt;npm pack&lt;/code&gt; but a &lt;strong&gt;bridge&lt;/strong&gt; that exports Deno/JSR to npm, and &lt;code&gt;run&lt;/code&gt;'s full Node compatibility remains a per-project thing to verify.&lt;/p&gt;

&lt;p&gt;If you smudge over this difference and write "Deno 2.8 swallowed the npm workflow whole," you get a marketing piece. If you surface the difference and split it into "this part is a replacement, that part is a different category," you get a technical piece. The most honest way to evaluate a new tool is always the same — &lt;strong&gt;don't trust the slogans; run it yourself on the same fixture.&lt;/strong&gt; It's the very attitude we confirmed in &lt;a href="https://var.gg/ko/blog/vite-8-rolldown" rel="noopener noreferrer"&gt;the case of Vite 8, where a framework was swallowing its build tool as a built-in&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Experiment environment: Windows 11 x64 · Node v24.15.0 / npm 11.12.1 · Deno 2.8.0. The fixture was composed of &lt;code&gt;lodash@4.17.15&lt;/code&gt;, &lt;code&gt;minimist@1.2.5&lt;/code&gt;, and &lt;code&gt;chalk@4.1.2&lt;/code&gt; (intentionally vulnerable versions for the audit comparison), and all numbers are local measurements from 2026-06-26. lockfile seed behavior may differ on Deno 2.9 and later.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>deno</category>
      <category>npm</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Create a Custom Cloudflare API Token (2026 Guide)</title>
      <dc:creator>curioustore</dc:creator>
      <pubDate>Sat, 27 Jun 2026 22:13:34 +0000</pubDate>
      <link>https://dev.to/curioustore_48788631d0e2e/how-to-create-a-custom-cloudflare-api-token-2026-guide-1766</link>
      <guid>https://dev.to/curioustore_48788631d0e2e/how-to-create-a-custom-cloudflare-api-token-2026-guide-1766</guid>
      <description>&lt;p&gt;Cloudflare's template tokens (Edit zone DNS, Workers AI, etc.) are the fastest path, but most of them are &lt;strong&gt;over-permissioned&lt;/strong&gt;. A leaked token with too many scopes causes a lot more damage than one that can only do exactly what your app needs.&lt;/p&gt;

&lt;p&gt;This guide shows the generic &lt;strong&gt;Custom Token&lt;/strong&gt; flow that works for any resource — R2 uploads, DNS automation, Workers deployments, cache purging, and more. We'll cover the minimum-permission mapping for the most common use cases in Step 04.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 01 — Click 'Create Token' on the API Tokens page
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start here&lt;/strong&gt;: &lt;a href="https://dash.cloudflare.com/profile/api-tokens" rel="noopener noreferrer"&gt;dash.cloudflare.com/profile/api-tokens&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From the Cloudflare dashboard, click the profile icon in the top-right to land on this page, then click the &lt;strong&gt;+ Create Token&lt;/strong&gt; button in the top right.&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%2Fimg.var.gg%2Fblog%2Fcloudflare-api-token-custom%2Fen%2Fstep01.webp" 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%2Fimg.var.gg%2Fblog%2Fcloudflare-api-token-custom%2Fen%2Fstep01.webp" title="Step 01 — Create Token button" alt="Location of the 'Create Token' button on the Cloudflare dashboard (English UI)" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!TIP]&lt;br&gt;
&lt;strong&gt;User API Tokens vs. Account API Tokens&lt;/strong&gt;: tokens created on this screen are scoped to &lt;em&gt;your user&lt;/em&gt;. For CI/CD or shared team services, Cloudflare recommends &lt;strong&gt;Account API Tokens&lt;/strong&gt; (created from the account-level admin area) so the token survives a person leaving. For personal automation scripts, user tokens are simpler.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 02 — Click 'Get started' under Custom token
&lt;/h2&gt;

&lt;p&gt;On the template selection page, click &lt;strong&gt;Get started&lt;/strong&gt; next to &lt;strong&gt;Create Custom Token&lt;/strong&gt; at the top. Everything below is a preconfigured template, but templates are usually broader than what you actually need.&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%2Fimg.var.gg%2Fblog%2Fcloudflare-api-token-custom%2Fen%2Fstep02.webp" 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%2Fimg.var.gg%2Fblog%2Fcloudflare-api-token-custom%2Fen%2Fstep02.webp" title="Step 02 — Custom token Get started" alt="Create Custom Token section on Cloudflare API token templates page (English UI)" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 03 — Enter a token name
&lt;/h2&gt;

&lt;p&gt;Give the token a name that tells future-you where it's used. When a token starts misbehaving months from now, the name is the only cue in the token list and in audit logs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recommended naming pattern:&lt;/strong&gt; &lt;code&gt;{project}-{purpose}-{environment}&lt;/code&gt;. Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;myapp-r2-uploader-prod&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;myapp-dns-updater-dev&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cicd-workers-deploy&lt;/code&gt;&lt;/li&gt;
&lt;/ul&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%2Fimg.var.gg%2Fblog%2Fcloudflare-api-token-custom%2Fen%2Fstep03.webp" 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%2Fimg.var.gg%2Fblog%2Fcloudflare-api-token-custom%2Fen%2Fstep03.webp" title="Step 03 — Token name" alt="Token name input field on the Create Custom Token page (English UI)" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 04 — Choose permissions (scope → resource → level)
&lt;/h2&gt;

&lt;p&gt;Permissions are built from three dropdowns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scope&lt;/strong&gt; — &lt;code&gt;Account&lt;/code&gt;, &lt;code&gt;Zone&lt;/code&gt;, or &lt;code&gt;User&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resource&lt;/strong&gt; — e.g. &lt;code&gt;Workers R2 Storage&lt;/code&gt;, &lt;code&gt;Zone DNS&lt;/code&gt;, &lt;code&gt;Cloudflare Workers Scripts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Level&lt;/strong&gt; — &lt;code&gt;Read&lt;/code&gt; or &lt;code&gt;Edit&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%2Fimg.var.gg%2Fblog%2Fcloudflare-api-token-custom%2Fen%2Fstep04.webp" 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%2Fimg.var.gg%2Fblog%2Fcloudflare-api-token-custom%2Fen%2Fstep04.webp" title="Step 04 — Permissions" alt="Permissions dropdowns on the Create Custom Token page (English UI)" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimum permissions by use case:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upload to an R2 bucket&lt;/td&gt;
&lt;td&gt;Account&lt;/td&gt;
&lt;td&gt;Workers R2 Storage&lt;/td&gt;
&lt;td&gt;Edit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automate DNS records&lt;/td&gt;
&lt;td&gt;Zone&lt;/td&gt;
&lt;td&gt;DNS&lt;/td&gt;
&lt;td&gt;Edit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy Workers (wrangler)&lt;/td&gt;
&lt;td&gt;Account&lt;/td&gt;
&lt;td&gt;Cloudflare Workers Scripts&lt;/td&gt;
&lt;td&gt;Edit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Purge cache only&lt;/td&gt;
&lt;td&gt;Zone&lt;/td&gt;
&lt;td&gt;Cache Purge&lt;/td&gt;
&lt;td&gt;Edit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read analytics&lt;/td&gt;
&lt;td&gt;Account&lt;/td&gt;
&lt;td&gt;Account Analytics&lt;/td&gt;
&lt;td&gt;Read&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;[!TIP]&lt;br&gt;
&lt;strong&gt;+ Add more&lt;/strong&gt; lets you stack multiple rows, but stuffing many purposes into one token makes rotation painful. &lt;strong&gt;One token, one purpose&lt;/strong&gt; is usually the right default.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The sections below — &lt;strong&gt;Account Resources / Zone Resources / Client IP Address Filtering / TTL&lt;/strong&gt; — are worth restricting too. If your deploy server has a static IP, &lt;strong&gt;Client IP Address Filtering&lt;/strong&gt; dramatically limits the blast radius if the token ever leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 05 — Click 'Continue to summary'
&lt;/h2&gt;

&lt;p&gt;Once permissions are set, click &lt;strong&gt;Continue to summary&lt;/strong&gt; at the bottom of the page to review.&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%2Fimg.var.gg%2Fblog%2Fcloudflare-api-token-custom%2Fen%2Fstep05.webp" 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%2Fimg.var.gg%2Fblog%2Fcloudflare-api-token-custom%2Fen%2Fstep05.webp" title="Step 05 — Continue to summary" alt="Continue to summary button on the Create Custom Token page (English UI)" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the review page, clicking the final &lt;strong&gt;Create Token&lt;/strong&gt; button displays the token string &lt;strong&gt;exactly once&lt;/strong&gt;. Closing that screen loses the token forever.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[!WARNING]&lt;br&gt;
&lt;strong&gt;Copy the token immediately to a safe place.&lt;/strong&gt; A password vault (1Password, Bitwarden) or your CI secrets manager is the right destination. If you suspect a leak, roll it right away from the token list page — the &lt;code&gt;...&lt;/code&gt; menu has a Roll option.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Picking a template because it's "faster"&lt;/strong&gt;: most templates are over-permissioned. &lt;code&gt;Edit zone DNS&lt;/code&gt; grants zone-wide DNS write for a token that's supposed to do R2. Custom feels slow the first time, but it's faster in the long run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Closing the final screen without copying&lt;/strong&gt;: the only recovery is to recreate the token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving TTL at No expiration&lt;/strong&gt;: the default is unlimited. Set an End Date at creation time or put a quarterly rotation reminder on your calendar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving IP filtering on while testing locally&lt;/strong&gt;: your home/café IP changes. Use IP filtering only for deploy environments and keep a separate token for local use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharing one token across teammates&lt;/strong&gt;: you lose per-person audit trails. Each person issues their own, or set up a service account with its own token.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/fundamentals/api/get-started/create-token/" rel="noopener noreferrer"&gt;Cloudflare docs: Create API token&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/fundamentals/api/reference/permissions/" rel="noopener noreferrer"&gt;Cloudflare docs: API token permissions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/r2/api/tokens/" rel="noopener noreferrer"&gt;Cloudflare R2: Authentication&lt;/a&gt; — for R2 use cases&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/workers/wrangler/ci-cd/" rel="noopener noreferrer"&gt;Cloudflare Workers CI/CD&lt;/a&gt; — for deploy automation&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Last verified: 2026-04-17 · English UI · Re-verified quarterly for drift&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>security</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Google Cloud OAuth Consent Screen + OAuth Client ID (2026 New UI)</title>
      <dc:creator>curioustore</dc:creator>
      <pubDate>Sat, 27 Jun 2026 22:13:33 +0000</pubDate>
      <link>https://dev.to/curioustore_48788631d0e2e/google-cloud-oauth-consent-screen-oauth-client-id-2026-new-ui-2908</link>
      <guid>https://dev.to/curioustore_48788631d0e2e/google-cloud-oauth-consent-screen-oauth-client-id-2026-new-ui-2908</guid>
      <description>&lt;h1&gt;
  
  
  Google Cloud OAuth Consent Screen + OAuth Client ID (2026 New UI)
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;[!NOTE]&lt;br&gt;
&lt;strong&gt;TL;DR&lt;/strong&gt;: 2025–2026 Google Cloud Console was reorganized into the &lt;strong&gt;Google Auth Platform&lt;/strong&gt; (Branding / Audience / Data Access / Clients), and the path to set up an OAuth consent screen changed completely. This guide walks from a fresh project to a Web Client ID/Secret in &lt;strong&gt;7 steps, ~10 minutes&lt;/strong&gt;. The two things that actually matter: &lt;strong&gt;picking [External] in the wizard&lt;/strong&gt; and &lt;strong&gt;getting the redirect URI exactly right&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why this guide exists
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Most existing English guides still describe the old UI and send readers chasing menus that no longer exist.&lt;/li&gt;
&lt;li&gt;The new UI starts with a "Get started" wizard — a single page with 4 stepper sections, not the old multi-screen flow.&lt;/li&gt;
&lt;li&gt;Pick External vs. Internal wrong and a regular web app will not work. The wizard does not let you change it later — you'd need a fresh project.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;A Google account&lt;/li&gt;
&lt;li&gt;Access to a GCP project (or create one in step 01)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing not required&lt;/strong&gt; — OAuth consent screen + Client ID setup are free&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 01 — Create a new GCP project
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start here&lt;/strong&gt;: &lt;a href="https://console.cloud.google.com/projectcreate?hl=en" rel="noopener noreferrer"&gt;console.cloud.google.com/projectcreate&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From the top-left project selector → "New project", or open the URL directly. Pick a recognizable name. The project ID is globally unique, so a numeric suffix may be appended automatically. &lt;strong&gt;Linking a billing account is NOT required for OAuth consent screen setup&lt;/strong&gt; — skip the prompt if it appears.&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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep01.webp" 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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep01.webp" title="Step 01" alt="Create a new GCP project" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Don't forget to &lt;strong&gt;switch to the new project from the selector&lt;/strong&gt; after creating it. Otherwise the next steps will configure OAuth on a different project.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 02 — Open the OAuth consent screen → [Get started]
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start here&lt;/strong&gt;: &lt;a href="https://console.cloud.google.com/auth/branding" rel="noopener noreferrer"&gt;console.cloud.google.com/auth/branding?project=YOUR_PROJECT_ID&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A fresh project shows the "Google Auth Platform not configured yet" notice. Click &lt;strong&gt;[Get started]&lt;/strong&gt; at the bottom right to enter the 4-step wizard.&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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep02.webp" 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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep02.webp" title="Step 02" alt="Open OAuth consent screen → Get started" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three ways to land here&lt;/strong&gt; (any of them works):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Left hamburger menu → &lt;strong&gt;APIs &amp;amp; Services → OAuth consent screen&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Top search bar → type &lt;code&gt;OAuth&lt;/code&gt; → click the result&lt;/li&gt;
&lt;li&gt;Open the URL above directly&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 03 — Pick [External] in the wizard (most common mistake)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start here&lt;/strong&gt;: &lt;a href="https://console.cloud.google.com/auth/overview/create" rel="noopener noreferrer"&gt;console.cloud.google.com/auth/overview/create?project=YOUR_PROJECT_ID&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The wizard is a single page with 4 stepper sections (1 → 2 → 3 → 4):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;App Information&lt;/strong&gt; — App name (shown on the consent screen), User support email (dropdown of your Google accounts)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audience (User Type)&lt;/strong&gt; — ⚠ &lt;strong&gt;the only real decision&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact Information&lt;/strong&gt; — Email Google uses for policy/notification updates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finish&lt;/strong&gt; — Accept the policy → [Create]&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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep03.webp" 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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep03.webp" title="Step 03" alt="Pick External in the wizard" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;External vs. Internal — pick once, can't change later (without a fresh project)&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Who can sign in?&lt;/th&gt;
&lt;th&gt;When to pick?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Internal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Only accounts in the same Google Workspace organization&lt;/td&gt;
&lt;td&gt;Internal tools (org account required)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;External&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Anyone with a Google Account&lt;/td&gt;
&lt;td&gt;Public web apps (starts in test mode)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;For a typical web app, always pick [External]&lt;/strong&gt;. External starts in "test mode" — only test users you add in step 04 can sign in until you publish the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 04 — [Audience] tab — add test users
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start here&lt;/strong&gt;: &lt;a href="https://console.cloud.google.com/auth/audience" rel="noopener noreferrer"&gt;console.cloud.google.com/auth/audience?project=YOUR_PROJECT_ID&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An External-type OAuth app starts in "Testing" mode. &lt;strong&gt;Only Google accounts on the [Test users] list can sign in&lt;/strong&gt; — everyone else hits an &lt;code&gt;access_blocked&lt;/code&gt; error (the actual face of "Error 403: access_denied" you've probably seen elsewhere).&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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep04.webp" 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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep04.webp" title="Step 04" alt="Audience tab — add test users" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click [&lt;strong&gt;Add users&lt;/strong&gt;] and register your own + teammate/QA emails (up to 100). Effective immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After publishing to production&lt;/strong&gt; anyone can sign in, but:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Non-sensitive scopes (openid/email/profile) → publish freely&lt;/li&gt;
&lt;li&gt;Sensitive scopes (Drive/Gmail/Calendar etc.) → Google verification process (weeks, security review)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 05 — [Data access] tab — add OAuth scopes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start here&lt;/strong&gt;: &lt;a href="https://console.cloud.google.com/auth/scopes" rel="noopener noreferrer"&gt;console.cloud.google.com/auth/scopes?project=YOUR_PROJECT_ID&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Define the scopes (permissions) shown to users on the consent screen.&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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep05.webp" 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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep05.webp" title="Step 05" alt="Data access tab — add OAuth scopes" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For typical Google Sign-In, three are enough&lt;/strong&gt; (all non-sensitive):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;openid&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.../auth/userinfo.email&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.../auth/userinfo.profile&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Click [&lt;strong&gt;Add or remove scopes&lt;/strong&gt;] → check the three in the right panel → [Update].&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope category&lt;/th&gt;
&lt;th&gt;What it accesses&lt;/th&gt;
&lt;th&gt;Google verification&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Non-sensitive&lt;/td&gt;
&lt;td&gt;Basic identity (email, profile)&lt;/td&gt;
&lt;td&gt;Not required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sensitive&lt;/td&gt;
&lt;td&gt;Some Drive/Gmail/Calendar&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restricted&lt;/td&gt;
&lt;td&gt;Full Gmail/Drive&lt;/td&gt;
&lt;td&gt;Required + security assessment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 06 — Web client + Authorized redirect URIs
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start here&lt;/strong&gt;: &lt;a href="https://console.cloud.google.com/auth/clients/create" rel="noopener noreferrer"&gt;console.cloud.google.com/auth/clients/create?project=YOUR_PROJECT_ID&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[Clients] tab → [&lt;strong&gt;Create Client&lt;/strong&gt;] to get here.&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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep06.webp" 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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep06.webp" title="Step 06" alt="Web Application client + redirect URI" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Form fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Application type&lt;/strong&gt;: &lt;strong&gt;Web application&lt;/strong&gt; for any browser-initiated OAuth flow (including BE servers exchanging tokens)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name&lt;/strong&gt;: just a label (e.g., &lt;code&gt;myapp-web-prod&lt;/code&gt;, &lt;code&gt;myapp-web-dev&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorized JavaScript origins&lt;/strong&gt;: only needed for SPAs (browser-only token retrieval). Leave blank if your BE handles it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorized redirect URIs&lt;/strong&gt;: the &lt;strong&gt;exact callback URL&lt;/strong&gt; ⚠&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;A single character mismatch in the redirect URI yields &lt;code&gt;redirect_uri_mismatch&lt;/code&gt;.&lt;/strong&gt; Common slip-ups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;http://&lt;/code&gt; vs. &lt;code&gt;https://&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Trailing slash (&lt;code&gt;/callback&lt;/code&gt; vs. &lt;code&gt;/callback/&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Port (&lt;code&gt;:3000&lt;/code&gt; present or not)&lt;/li&gt;
&lt;li&gt;Missing dev/staging/prod URIs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;http://localhost:3000/...&lt;/code&gt; works for local dev. Multiple URIs allowed on the same client.&lt;/p&gt;

&lt;p&gt;Then [Create].&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 07 — Save Client ID + Secret (Secret won't be shown again)
&lt;/h2&gt;

&lt;p&gt;Right after [Create], the &lt;strong&gt;OAuth client created&lt;/strong&gt; dialog appears.&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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep07.webp" 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%2Fimg.var.gg%2Fblog%2Fgcp-oauth-consent-client-id%2Fen%2Fstep07.webp" title="Step 07" alt="Save Client ID + Secret" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Credential&lt;/th&gt;
&lt;th&gt;Public OK?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Client ID&lt;/strong&gt; (&lt;code&gt;123-abc.apps.googleusercontent.com&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;✓ Public OK (the identifier visible in browsers)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Client Secret&lt;/strong&gt; (&lt;code&gt;GOCSPX-...&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;✗ &lt;strong&gt;Never expose&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Once the dialog closes, the Secret is never shown again.&lt;/strong&gt; You'd need to issue a new secret or recreate the client.&lt;/p&gt;

&lt;p&gt;Click [&lt;strong&gt;Download JSON&lt;/strong&gt;] and:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Move it to &lt;code&gt;.env&lt;/code&gt; or a password manager (1Password, Bitwarden, etc.) immediately&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never commit to Git&lt;/strong&gt; — add &lt;code&gt;*.env&lt;/code&gt;, &lt;code&gt;client_secret*.json&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Delete the downloaded JSON file from disk after saving&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;If exposed, rotate immediately&lt;/strong&gt;: [Clients] tab → the client → "Add/Revoke secret" to issue a new one and expire the old.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Picked Internal&lt;/strong&gt; → web app only accepts org accounts. You'd need a fresh project (External isn't reachable later).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgot to register test users&lt;/strong&gt; → you can sign in but teammates/QA hit &lt;code&gt;access_blocked&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect URI off by a character&lt;/strong&gt; → &lt;code&gt;redirect_uri_mismatch&lt;/code&gt;. Register every environment URI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardcoded Client Secret in source instead of .env&lt;/strong&gt; → exposed on Git push. Use a pre-commit hook (&lt;code&gt;gitleaks&lt;/code&gt;, &lt;code&gt;detect-secrets&lt;/code&gt;) to block it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding sensitive scopes and trying to publish without verification&lt;/strong&gt; → Google verification required. Ship the first version with non-sensitive scopes (openid/email/profile) only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Navigating away mid-wizard&lt;/strong&gt; → wizard ends in an incomplete state. Start over from step 02.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Picked iOS/Android/Desktop instead of Web Application&lt;/strong&gt; → wrong OAuth flow, won't fit your BE server code.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/identity/protocols/oauth2" rel="noopener noreferrer"&gt;Google Identity — Official docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/identity/protocols/oauth2/web-server" rel="noopener noreferrer"&gt;Web server OAuth flow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.google.com/cloud/answer/13463073" rel="noopener noreferrer"&gt;OAuth verification (sensitive scopes)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://console.cloud.google.com/auth" rel="noopener noreferrer"&gt;Google Cloud Console (Auth Platform)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Written against the 2026-04-18 Google Cloud Console UI. The UI may shift again — substitute your own project ID into the URLs above when following along.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googlecloud</category>
      <category>oauth</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The agent writes the code now — what should a git tool be?</title>
      <dc:creator>curioustore</dc:creator>
      <pubDate>Sat, 27 Jun 2026 18:42:27 +0000</pubDate>
      <link>https://dev.to/curioustore_48788631d0e2e/the-agent-writes-the-code-now-what-should-a-git-tool-be-1jeb</link>
      <guid>https://dev.to/curioustore_48788631d0e2e/the-agent-writes-the-code-now-what-should-a-git-tool-be-1jeb</guid>
      <description>&lt;p&gt;For years I ran VS Code as a git client. Not really an editor — a GitLens host. The branch graph, the heat-mapped blame, the inline lens annotations: that &lt;em&gt;was&lt;/em&gt; my git workflow.&lt;/p&gt;

&lt;p&gt;Then 2025 happened, and that workflow quietly fell apart. Somewhere in there I realized git tools had been built for the wrong person all along.&lt;/p&gt;

&lt;h2&gt;
  
  
  GitLens kept me tied to the editor
&lt;/h2&gt;

&lt;p&gt;GitLens is a great extension. Every commit annotated next to the code, a file's history as a heat map, the branch graph on a single screen. I used it for years.&lt;/p&gt;

&lt;p&gt;The catch is that GitLens only lives &lt;em&gt;inside the editor&lt;/em&gt;. To look at commit history, VS Code has to be open. For a long time that wasn't a cost — I was in the editor all day anyway. I was the one typing the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  2025: my hands left the keyboard
&lt;/h2&gt;

&lt;p&gt;Then Cursor, Claude Code, and Codex showed up. At first it felt like autocomplete with a longer reach. Then I looked up and the agent was doing the actual editing — writing functions, refactoring across files, even making the commits. I direct, I read, I judge.&lt;/p&gt;

&lt;p&gt;The role flipped. I'm not the &lt;em&gt;author&lt;/em&gt; of the code anymore so much as its &lt;em&gt;reviewer&lt;/em&gt;. I spend more time looking at what the agent just did than with my hands on the keys.&lt;/p&gt;

&lt;p&gt;And at that point VS Code started to feel like overhead. The agent runs in a terminal. I had no reason to keep an editor open — except one: GitLens. Booting an entire IDE just to glance at a list of commits suddenly looked absurd.&lt;/p&gt;

&lt;h2&gt;
  
  
  Git tools are still built for the author
&lt;/h2&gt;

&lt;p&gt;Here is the real problem. Almost every git tool — GitLens, Sourcetree, GitHub Desktop, lazygit, the editor's built-in SCM panel — assumes a person who &lt;em&gt;writes the code by hand&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So they put commit, stage, push, merge, rebase, and cherry-pick front and center. They go deep on blame. They pour effort into conflict-resolution UI. All of it is what an author needs.&lt;/p&gt;

&lt;p&gt;But in the agent era my git usage looks different. Most of the time I am just:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;seeing &lt;em&gt;what the agent just committed&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;deciding whether it &lt;em&gt;makes sense&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;expanding the &lt;em&gt;diff&lt;/em&gt; when something looks off&lt;/li&gt;
&lt;li&gt;telling the &lt;em&gt;agent to redo it&lt;/em&gt; when it is wrong&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nowhere in that loop do I commit, push, or merge by hand. If git surgery is needed, it is faster to tell the agent in words. Yet the existing tools spend their screen space and their weight on exactly the surgery features I now barely touch.&lt;/p&gt;

&lt;p&gt;The tools are not wrong. The user they were designed for is just from an earlier era.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a git tool for the reviewer looks like
&lt;/h2&gt;

&lt;p&gt;So what should a git tool for the reviewer be? I landed on four things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It should be read-only.&lt;/strong&gt; A tool whose job is verification should not be able to change state by accident. If there is no commit/push/merge button, there is no button to hit by mistake. Read-only is not a missing feature — it is a design decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. It should be resident, not a window.&lt;/strong&gt; Reviewing is a short action that happens dozens of times a day. Launching and closing an app each time is friction. Better to sit in the tray, expand on a single click, and get out of the way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It should be done in half a second.&lt;/strong&gt; "Did the agent just do that right?" is a half-second question. If the tool takes longer than that, people simply skip the check. For a glance, speed &lt;em&gt;is&lt;/em&gt; the feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. It should not try to be where you fix things — it should hand off to the agent.&lt;/strong&gt; When something is wrong, the tool does not need to let you fix it in place. Bundle the commit and the diff into clean context and hand it straight to the agent. The agent is better at the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I built gitwink
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/var-gg/gitwink" rel="noopener noreferrer"&gt;gitwink&lt;/a&gt; is those four things, built.&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%2F84c6n8pfn6ft42xki4c6.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F84c6n8pfn6ft42xki4c6.gif" alt="gitwink — a unified commit timeline that expands from the tray" width="520" height="484"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;gitwink lives in your system tray. Click it — or hit &lt;code&gt;Ctrl+Shift+G&lt;/code&gt; — and a panel expands with the recent commits across &lt;em&gt;all&lt;/em&gt; of your local repos on one timeline. Filter by repo, time range, or author; click a commit and the message body and changed-file list expand inline.&lt;/p&gt;

&lt;p&gt;For the &lt;em&gt;"wait, did the agent actually do that?"&lt;/em&gt; moments, a separate diff window opens a side-by-side view — with before/after preview for image assets.&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%2F2lt5bon6zc6lgtx78q5n.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2lt5bon6zc6lgtx78q5n.gif" alt="gitwink diff window — side-by-side diff with inline image preview" width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the key part: &lt;strong&gt;Copy as AI context&lt;/strong&gt;. One press of &lt;code&gt;c&lt;/code&gt; and the commit, the file list, and the diff land on your clipboard as a markdown block. Paste it into Claude or Codex and ask "is this right?". The tool does not take the seat of judgment — it just hands over clean material to judge.&lt;/p&gt;

&lt;p&gt;gitwink is &lt;em&gt;not&lt;/em&gt; a git client. It cannot commit, push, or merge. It is read-only by design. No telemetry, no phone-home, no network calls — it is a small local app built with Tauri 2 and Rust, and the code is MIT.&lt;/p&gt;

&lt;p&gt;Windows and macOS builds are on the &lt;a href="https://github.com/var-gg/gitwink/releases/latest" rel="noopener noreferrer"&gt;latest release&lt;/a&gt;. (The artefacts are not code-signed yet, so SmartScreen / Gatekeeper will warn on first launch — the release notes have the bypass steps.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;As agents started writing the code, a developer's job shifted from &lt;em&gt;authoring&lt;/em&gt; to &lt;em&gt;reviewing&lt;/em&gt;. But most git tools are still designed for the author. A reviewer needs something that is read-only, resident, fast, and able to hand off to the agent. gitwink is the smallest version of that idea I could build.&lt;/p&gt;

</description>
      <category>git</category>
      <category>ai</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>gitwink — a read-only tray git glance for the AI-agent era</title>
      <dc:creator>curioustore</dc:creator>
      <pubDate>Thu, 28 May 2026 01:32:05 +0000</pubDate>
      <link>https://dev.to/curioustore_48788631d0e2e/gitwink-a-read-only-tray-git-glance-for-the-ai-agent-era-2km0</link>
      <guid>https://dev.to/curioustore_48788631d0e2e/gitwink-a-read-only-tray-git-glance-for-the-ai-agent-era-2km0</guid>
      <description>&lt;p&gt;I used to live in VS Code with GitLens pinned — the branch graph, heat-mapped blame, the lens annotations. That &lt;em&gt;was&lt;/em&gt; my git workflow.&lt;/p&gt;

&lt;p&gt;Then 2026 happened. With Cursor, Claude Code, and Codex doing the actual editing, the editor itself became optional. The only thing dragging me back was GitLens.&lt;/p&gt;

&lt;p&gt;That felt wasteful — booting an entire IDE just to peek at commit history. The agent runs the git commands now; I only need to sanity-check the result, occasionally, when something looks off.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://github.com/var-gg/gitwink" rel="noopener noreferrer"&gt;gitwink&lt;/a&gt;&lt;/strong&gt; — the smallest possible tool for &lt;em&gt;that&lt;/em&gt; loop. A tray icon that expands into a glance, hands the commit off as AI context, and gets out of the way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only by design.&lt;/strong&gt; It cannot commit, push, merge, or modify anything. If I need git surgery, I tell the agent.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  The 0.5-second confirm loop
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;agent commits  →  tray click  →  inline expand  →  "Copy as AI context"
                                                  →  paste into Claude/Codex/Cursor
                                                  →  "did the agent do this right?"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No window switching. No IDE boot. The whole loop fits inside a glance.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tray-resident&lt;/strong&gt; (Windows tray / macOS menu bar) — click to toggle, global hotkey &lt;code&gt;Ctrl+Shift+G&lt;/code&gt; to summon from anywhere. Right-click the tray icon for Reset position / Open settings file / Quit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-run discovery&lt;/strong&gt; that walks your usual code dirs (&lt;code&gt;source&lt;/code&gt;, &lt;code&gt;Documents&lt;/code&gt;, &lt;code&gt;Projects&lt;/code&gt;, &lt;code&gt;Code&lt;/code&gt;, &lt;code&gt;Dev&lt;/code&gt;, &lt;code&gt;repos&lt;/code&gt;, &lt;code&gt;Desktop&lt;/code&gt;, every non-system drive on Windows; &lt;code&gt;~/Projects&lt;/code&gt;, &lt;code&gt;~/Code&lt;/code&gt;, &lt;code&gt;~/Developer&lt;/code&gt; on macOS) and caches the result in SQLite. No "add repo" friction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unified commit timeline&lt;/strong&gt; across all repos, with chips above for filtering by Repo (search + pinning), Time range (24h / 3d / 7d / 30d / All), and Authors (multi-select with counts). Per-row markers — &lt;code&gt;●&lt;/code&gt; commit · &lt;code&gt;◆&lt;/code&gt; merge · &lt;code&gt;★&lt;/code&gt; tagged — and branch label badges when a commit isn't on the currently checked-out branch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single-repo DAG view&lt;/strong&gt; — pick a repo and the panel switches to a per-branch graph with a custom SVG lane drawer (eight-colour palette, hashed from branch name; main / master / develop kept neutral).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline expand on click&lt;/strong&gt; — commit body + changed-file list with NEW/MOD/REN/DEL badges, &lt;code&gt;+/−&lt;/code&gt; line counts, &lt;code&gt;bin&lt;/code&gt; + size for binaries, GitLens-style filename emphasis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate diff window&lt;/strong&gt; for the full read — file sidebar + side-by-side diff with synchronised horizontal scroll, PNG / JPG / GIF / WebP / SVG image preview (before / after, with checker background). Singleton, remembers position, size, and maximised state. Local Git LFS objects are looked up automatically; missing ones are explained inline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy as AI context&lt;/strong&gt; — &lt;code&gt;c&lt;/code&gt; key or button. Produces a markdown block with the commit, file list, and (if small enough) the full diff, ready to paste into Claude / Codex / Cursor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The diff window
&lt;/h2&gt;

&lt;p&gt;For the &lt;em&gt;"wait, did the agent actually do that?"&lt;/em&gt; moments. Click any commit and a separate window opens — full file sidebar, side-by-side diff with synchronised scroll, inline image preview for binary assets, and a singleton that remembers position, size, and maximised state.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tauri.app/" rel="noopener noreferrer"&gt;Tauri 2&lt;/a&gt;&lt;/strong&gt; — Rust core, web frontend, native tray&lt;/li&gt;
&lt;li&gt;Rust + &lt;a href="https://crates.io/crates/git2" rel="noopener noreferrer"&gt;&lt;code&gt;git2&lt;/code&gt;&lt;/a&gt; for the git plumbing&lt;/li&gt;
&lt;li&gt;React + TypeScript for the panel&lt;/li&gt;
&lt;li&gt;SQLite for the repo discovery cache&lt;/li&gt;
&lt;li&gt;Custom SVG DAG drawer (eight-colour palette, hashed from branch name)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;No telemetry. No phone-home.&lt;/strong&gt; The only network access is an opt-out update check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Microsoft Store&lt;/strong&gt; — &lt;a href="https://apps.microsoft.com/detail/9P0S21GJD53F" rel="noopener noreferrer"&gt;gitwink on the Microsoft Store →&lt;/a&gt;. The Store build is signed by Microsoft during certification, so no SmartScreen prompt appears, and the Store owns updates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WinGet:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Scoop:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scoop bucket add var-gg https://github.com/var-gg/scoop-bucket
scoop &lt;span class="nb"&gt;install &lt;/span&gt;gitwink
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scoop installs by extraction, so no SmartScreen prompt either. Update later with &lt;code&gt;scoop update gitwink&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or grab the release directly&lt;/strong&gt; — &lt;a href="https://github.com/var-gg/gitwink/releases/latest" rel="noopener noreferrer"&gt;latest release on GitHub&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows&lt;/strong&gt; — &lt;code&gt;.exe&lt;/code&gt; (NSIS installer) or &lt;code&gt;.msi&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS&lt;/strong&gt; — &lt;code&gt;.dmg&lt;/code&gt; (universal)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Direct downloads are currently unsigned (gitwink participates in the &lt;a href="https://signpath.org/" rel="noopener noreferrer"&gt;SignPath Foundation&lt;/a&gt; free code-signing program for OSS; the certificate will sign these artefacts once approved). The release notes have the SmartScreen / Gatekeeper bypass steps.&lt;/p&gt;

&lt;p&gt;Building from source is straightforward — &lt;code&gt;pnpm install &amp;amp;&amp;amp; pnpm tauri dev&lt;/code&gt;. Requires Node 20+, Rust stable (msvc toolchain on Windows), Visual C++ Build Tools or Xcode CLT.&lt;/p&gt;

&lt;h2&gt;
  
  
  Status
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;v0.4&lt;/strong&gt; — usable, daily-driven. Cold-start friendly tray app.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Windows 10/11&lt;/strong&gt; — primary target, tested on dev hardware&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;macOS 13+&lt;/strong&gt; — should work, less battle-tested&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux&lt;/strong&gt; — later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/var-gg/gitwink" rel="noopener noreferrer"&gt;&lt;strong&gt;Source on GitHub →&lt;/strong&gt;&lt;/a&gt; (MIT licensed)&lt;/p&gt;

&lt;p&gt;If the AI-agent workflow describes your week too, give it a wink. Feedback and issues welcome on &lt;a href="https://github.com/var-gg/gitwink/issues" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>rust</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
