<?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: Gabor Koos</title>
    <description>The latest articles on DEV Community by Gabor Koos (@gkoos).</description>
    <link>https://dev.to/gkoos</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%2F3401857%2F2cf3748e-81c3-44f6-b904-30c68de2747c.jpeg</url>
      <title>DEV Community: Gabor Koos</title>
      <link>https://dev.to/gkoos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gkoos"/>
    <language>en</language>
    <item>
      <title>How I Built a Static Ecosystem Site for My Open Source Tools (Eleventy + Tailwind + GitHub Pages)</title>
      <dc:creator>Gabor Koos</dc:creator>
      <pubDate>Sun, 14 Jun 2026 20:10:22 +0000</pubDate>
      <link>https://dev.to/gkoos/how-i-built-a-static-ecosystem-site-for-my-open-source-tools-eleventy-tailwind-github-pages-1p61</link>
      <guid>https://dev.to/gkoos/how-i-built-a-static-ecosystem-site-for-my-open-source-tools-eleventy-tailwind-github-pages-1p61</guid>
      <description>&lt;p&gt;I've been building the &lt;a href="https://github.com/fetch-kit/" rel="noopener noreferrer"&gt;fetch-kit&lt;/a&gt; ecosystem for about a year - &lt;strong&gt;ffetch&lt;/strong&gt; (a fetch wrapper with resiliency features), &lt;strong&gt;chaos-fetch&lt;/strong&gt; (a network failure testing tool), &lt;strong&gt;chaos-proxy&lt;/strong&gt; (a proxy to inject network chaos), and the &lt;strong&gt;chaos arena&lt;/strong&gt; (to set up different network failure modes). Each tool has its own GitHub repo and README, but there was no single place that answered "what is this, when do I use it, and how does it all fit together"? Someone landing on the &lt;a href="https://github.com/fetch-kit/ffetch" rel="noopener noreferrer"&gt;ffetch&lt;/a&gt; repo had no idea &lt;a href="https://github.com/fetch-kit/chaos-fetch" rel="noopener noreferrer"&gt;chaos-fetch&lt;/a&gt; existed, let alone the &lt;a href="https://fetchkit.org/ffetch-demo/" rel="noopener noreferrer"&gt;arena&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://fetchkit.org" rel="noopener noreferrer"&gt;fetchkit.org&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Eleventy (v3) for static site generation - Nunjucks templates, fast builds, zero client-side framework overhead&lt;/li&gt;
&lt;li&gt;Tailwind CSS v4 with a custom dark color palette derived from the logo background color&lt;/li&gt;
&lt;li&gt;GitHub Pages for hosting with a custom GitHub Actions workflow that builds on push to main and deploys the dist folder&lt;/li&gt;
&lt;li&gt;Cloudflare for DNS (with A records kept grey/DNS-only - GitHub Pages does its own cert verification and breaks if it sees Cloudflare IPs instead of its own)&lt;/li&gt;
&lt;li&gt;GoatCounter for privacy-friendly analytics&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Each tool gets its own page with: what it does, code examples, a comparison table (e.g. chaos-fetch vs chaos-proxy), and links to the blog posts I've already written about it. The homepage has a news widget that pulls from my blog's RSS feed filtered by the fetch-kit tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cloudflare gotcha
&lt;/h2&gt;

&lt;p&gt;Worth calling out: if you host on GitHub Pages with a custom domain and Cloudflare, keep your A records as DNS only (grey cloud), not proxied. GitHub periodically re-verifies that the domain resolves to its own IPs. With Cloudflare proxying on, it sees Cloudflare IPs, marks the domain as misconfigured, and stops serving it. The CNAME for www can be proxied fine - only the apex A records matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source
&lt;/h2&gt;

&lt;p&gt;The site source is at github.com/fetch-kit/fetch-kit.github.io if you want to see the Eleventy + Tailwind v4 setup.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>github</category>
    </item>
    <item>
      <title>Your Package Manager Is Lying to You</title>
      <dc:creator>Gabor Koos</dc:creator>
      <pubDate>Fri, 12 Jun 2026 00:12:15 +0000</pubDate>
      <link>https://dev.to/gkoos/your-package-manager-is-lying-to-you-gfc</link>
      <guid>https://dev.to/gkoos/your-package-manager-is-lying-to-you-gfc</guid>
      <description>&lt;p&gt;Package managers are usually treated as interchangeable tooling: install dependencies, commit the lockfile, and move on. In that framing, the only question that seems to matter is performance.&lt;/p&gt;

&lt;p&gt;In practice, the differences run much deeper. npm, Yarn, and pnpm are built on fundamentally different models of what &lt;code&gt;node_modules&lt;/code&gt; should be: different assumptions about how dependencies should be represented on disk, how strictly boundaries should be enforced, and how much implicit behavior the ecosystem should tolerate.&lt;/p&gt;

&lt;p&gt;Bun and Deno go further: they challenge the model itself. Bun treats the entire developer loop as something that should feel instantaneous. Deno folds dependency management into a broader security and web-native runtime philosophy.&lt;/p&gt;

&lt;p&gt;This is why migrations often feel disproportionate. The lockfile might be perfect and your application code untouched, yet builds break because scripts, plugins, and tools were written against a different set of invariants about the filesystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The real axis of difference isn't speed or disk usage, but how each tool chooses to represent and resolve dependencies.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every package manager is a different compromise between what physically exists on disk and what the ecosystem expects to find. Those tradeoffs are easiest to see through five competing goals.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Five Competing Goals
&lt;/h2&gt;

&lt;p&gt;Every package manager is trying to optimize the same things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reproducible installs&lt;/li&gt;
&lt;li&gt;Install speed and cache reuse&lt;/li&gt;
&lt;li&gt;Disk efficiency across multiple projects&lt;/li&gt;
&lt;li&gt;Compatibility with the Node ecosystem as it exists today&lt;/li&gt;
&lt;li&gt;Developer experience (integrated tooling, lower friction, safer defaults)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No tool can maximize all five at once. The practical choice is usually the one whose failure mode you’re most willing to tolerate.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;a href="https://www.npmjs.com/" rel="noopener noreferrer"&gt;npm&lt;/a&gt;: The Pragmatic Baseline (Compatibility Over Correctness)
&lt;/h2&gt;

&lt;p&gt;npm is the default package manager for Node.js and has been since its early days. If you use Node, you have npm. This gives npm a huge advantage in terms of ecosystem compatibility and developer familiarity, but it also means npm has had to make compromises to maintain that compatibility over time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core Model
&lt;/h3&gt;

&lt;p&gt;npm flattens the dependency tree by placing packages as high as possible in &lt;code&gt;node_modules&lt;/code&gt;, then falls back to nested subdirectories only when version conflicts force it. This simple strategy was born from pragmatism: Node's module resolution algorithm walks up the directory tree looking for &lt;code&gt;node_modules&lt;/code&gt;, so the flatter the structure, the faster the lookup and the fewer surprises developers encounter. The strategy has persisted because it still broadly works with how the Node ecosystem is wired.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design Philosophy
&lt;/h3&gt;

&lt;p&gt;npm's philosophy is pragmatic continuity: rather than enforce a strict model of dependency access, npm prioritizes keeping the ecosystem running as it currently runs. This means tolerating patterns that are structurally impure if those patterns are common in the wild. A new, stricter model might be more correct, but it would break existing code and tooling, so npm's design philosophy is to bend toward the ecosystem rather than ask the ecosystem to bend toward it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;p&gt;This design brings several concrete advantages. Compatibility is the biggest: almost every tool in the JavaScript ecosystem was first tested and optimized for npm's semantics, so migrating away from npm often means discovering edge cases in other tooling. Setup is minimal, which matters for teams that don't want to spend cycles learning tooling; npm just works by default. And there is real value in being bundled with Node itself: it means npm is always available, always installed, and always familiar to anyone with Node on their machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Weaknesses
&lt;/h3&gt;

&lt;p&gt;The cost of this compatibility-first approach is structural ambiguity. Hoisting can make undeclared dependencies accidentally available, which means the actual runtime dependency graph often differs from what &lt;code&gt;package.json&lt;/code&gt; files claim. On larger codebases, this ambiguity compounds: &lt;code&gt;node_modules&lt;/code&gt; can become very large and performance can degrade in monorepos or CI pipelines where many projects share the same machine. Install times are generally slower than newer, store-based approaches, especially when you are running the same installs repeatedly across different projects or CI runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Lie It Tells
&lt;/h3&gt;

&lt;p&gt;npm's lie is a useful one: it suggests that a package's runtime behavior matches its declared dependencies. In reality, a package can often reach into the hoisted tree and use packages it never declared, simply because those packages were placed somewhere reachable. The discrepancy is usually invisible until something changes—a new dependency, a version conflict, or a different install layout on a different machine—and suddenly that undeclared access no longer works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;

&lt;p&gt;Suppose your app declares &lt;code&gt;react&lt;/code&gt;, but one of your dependencies declares &lt;code&gt;lodash&lt;/code&gt;. npm hoists both to the top level of &lt;code&gt;node_modules&lt;/code&gt;. If your app imports &lt;code&gt;lodash&lt;/code&gt; directly, it will work—and the import may be there because someone saw it was available and used it for convenience. Months later, you update a different dependency, or remove the one that was declaring &lt;code&gt;lodash&lt;/code&gt;. Now npm's hoisting algorithm arranges things differently, and &lt;code&gt;lodash&lt;/code&gt; is no longer at the top level. Your app's direct &lt;code&gt;import lodash&lt;/code&gt; suddenly fails, and the error looks baffling because you never explicitly declared the dependency and the import appears to be fine in the code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Best for
&lt;/h3&gt;

&lt;p&gt;npm remains the default choice for most teams, especially those building straightforward applications, maintaining legacy codebases, or operating in environments where broad ecosystem compatibility matters more than perfect structural correctness. If your priority is minimizing friction and maximizing the number of third-party packages and tools that work without special configuration, npm is still usually the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;a href="https://yarnpkg.com/" rel="noopener noreferrer"&gt;Yarn (Berry)&lt;/a&gt;: Reproducibility as a Response to npm's Early Chaos
&lt;/h2&gt;

&lt;p&gt;When people refer to "Yarn" in 2026, there is often ambiguity about which version they mean. Yarn Classic (v1) was released as a faster alternative to npm in the npm v3–v4 era and is still widely used in legacy projects. Yarn Berry (v2 and later) is a much more ambitious reimagining, released around 2019, that fundamentally questions whether &lt;code&gt;node_modules&lt;/code&gt; should exist at all. This section focuses on Yarn Berry, because that is where Yarn's design philosophy is most visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core Model
&lt;/h3&gt;

&lt;p&gt;Yarn Berry combines a strong lockfile system with Plug'n'Play (PnP) as its default linker mode, which can abandon the traditional &lt;code&gt;node_modules&lt;/code&gt; directory entirely. In PnP mode, Yarn uses a &lt;code&gt;.pnp.cjs&lt;/code&gt; file to map each package to its location in a global cache, and module resolution is intercepted to consult that map. Berry is not limited to PnP, though: it also supports alternative linker modes that use &lt;code&gt;node_modules&lt;/code&gt; layouts (including npm-like and pnpm-like behavior), which can reduce migration friction for ecosystems that still assume on-disk trees. The lockfile is deterministic to the byte, meaning the same &lt;code&gt;yarn.lock&lt;/code&gt; on any machine will always produce the exact same dependency tree and artifacts. If you want, you can run &lt;code&gt;yarn install --immutable&lt;/code&gt; and ship the dependencies as part of your repository, enabling zero-install setups where CI does not need to download or build anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design Philosophy
&lt;/h3&gt;

&lt;p&gt;Yarn's philosophy is reproducibility as a first-class concern and extensibility as a design principle. Every decision in Yarn Berry prioritizes the ability to reproduce an install exactly, down to the checksum of every file. The plugin system allows customization at nearly every step of the install process, which appeals to large organizations that need to enforce internal policies or integrate custom private registries and tooling. Yarn treats package management as a build artifact that deserves the same rigor as compiled code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;p&gt;Yarn delivers on reproducibility in ways that are genuinely powerful for large teams. Every install is deterministic and can be verified; there is no ambiguity about what your project depends on or how it was resolved. The plugin ecosystem is extensive, allowing organizations to customize resolution, transport, and authentication without forking the entire tool. Zero-install workflows are real: you can ship &lt;code&gt;.yarn/cache&lt;/code&gt; and &lt;code&gt;.pnp.cjs&lt;/code&gt; in version control so anyone cloning the repo can start work immediately without running &lt;code&gt;yarn install&lt;/code&gt;. For enterprise teams managing complex monorepos with internal tooling, Yarn's flexibility can be a major advantage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Weaknesses
&lt;/h3&gt;

&lt;p&gt;The cost of this power is complexity and ecosystem friction. Plug'n'Play mode breaks many tools that assume a traditional &lt;code&gt;node_modules&lt;/code&gt; directory exists. Older packages may have scripts that do &lt;code&gt;fs.readdirSync('node_modules')&lt;/code&gt; or similar filesystem introspection, and those simply fail under PnP. Even many modern tools can behave unexpectedly because they were written for &lt;code&gt;node_modules&lt;/code&gt; semantics. Build tools, bundlers, and testing frameworks often need special configuration or plugins to work well with PnP. Using node_modules-based linker modes can reduce some of that migration pain, but you also give up part of PnP's strictness and determinism story. Yarn Berry adoption is still much smaller than npm or pnpm, so community support and third-party integration are less mature. For teams that do not have a dedicated DevOps or tooling function, Yarn's flexibility can feel like unnecessary overhead.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Lie It Tells
&lt;/h3&gt;

&lt;p&gt;Yarn's lie is seductive: that you can completely abstract away &lt;code&gt;node_modules&lt;/code&gt; and replace it with something cleaner and more reproducible without paying a compatibility tax. The reality is that the Node ecosystem is deeply wired around the expectation of a physical &lt;code&gt;node_modules&lt;/code&gt; directory, and that wiring is stronger than Yarn's tooling can fully hide. Tools that Yarn does not control will still expect &lt;code&gt;node_modules&lt;/code&gt; to exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;

&lt;p&gt;You enable PnP mode and everything works locally. Your build tool has a Yarn plugin, your test runner runs fine, and the install is instant. Then a team member uses a script that was written by someone else in your organization years ago, or tries to use a third-party tool that does &lt;code&gt;require.resolve()&lt;/code&gt; with filesystem assumptions, and it fails because the packages are not actually on disk in &lt;code&gt;node_modules&lt;/code&gt; anymore. You can often fix this by adding a Yarn plugin or switching to a different tool, but each fix is a small friction point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Best for
&lt;/h3&gt;

&lt;p&gt;Yarn Berry is best suited to large organizations with dedicated tooling teams, complex monorepos, or projects where reproducibility and extensibility are more valuable than broad tool compatibility. If you are willing to invest in understanding and maintaining Yarn's plugin system, or if your CI environment is fully under your control and can be customized, Yarn offers genuine advantages. For smaller teams or projects that need to work smoothly with a wide variety of third-party tools out of the box, Yarn's cost-to-benefit ratio is often too high.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;a href="https://pnpm.io/" rel="noopener noreferrer"&gt;pnpm&lt;/a&gt;: Structural Correctness Through Isolation
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Note that I am biased towards pnpm, because it was created by a fellow Hungarian called Zoltan Kochan.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;pnpm takes a different approach to the same problems that motivated Yarn. Rather than try to abstract away &lt;code&gt;node_modules&lt;/code&gt;, pnpm makes &lt;code&gt;node_modules&lt;/code&gt; stricter and more honest about what dependencies are actually available.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core Model
&lt;/h3&gt;

&lt;p&gt;pnpm uses a content-addressable global store and symlinks to build a non-flattened &lt;code&gt;node_modules&lt;/code&gt; tree. Each package in your project gets its own &lt;code&gt;node_modules&lt;/code&gt; directory containing only the packages it directly declares, plus symlinks to those packages' dependencies. This means a package's &lt;code&gt;node_modules&lt;/code&gt; mirrors its &lt;code&gt;package.json&lt;/code&gt; exactly: if it declares &lt;code&gt;lodash&lt;/code&gt;, &lt;code&gt;lodash&lt;/code&gt; is there; if it does not, &lt;code&gt;lodash&lt;/code&gt; is not accessible through the filesystem, even if some other package brought it in. The global store deduplicates identical copies of the same package across multiple projects, which saves substantial disk space, especially in monorepo environments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design Philosophy
&lt;/h3&gt;

&lt;p&gt;pnpm's philosophy is structural correctness and efficiency. The core belief is that the dependency graph should be explicit and strict: if a package declares a dependency, it should be there; if it does not, it should not be. This honesty creates friction with the ecosystem, but it also exposes bugs and bad practices that npm and Yarn hide. pnpm's secondary goal is disk efficiency, achieved through content-addressable storage and symlinks rather than through hoisting or abstraction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;p&gt;pnpm delivers tangible benefits for the specific use cases it targets. Disk savings are real and measurable, especially in monorepos or CI environments where many projects are installed on the same machine; a global store means installing &lt;code&gt;lodash&lt;/code&gt; a second time costs almost nothing. Isolation eliminates phantom dependencies entirely, so your code is forced to match what your &lt;code&gt;package.json&lt;/code&gt; claims. Installation is fast, both because the global store avoids duplication and because pnpm can leverage hard links and copy-on-write in certain environments. For monorepos in particular, pnpm's performance characteristics are better than npm's in almost every scenario.&lt;/p&gt;

&lt;h3&gt;
  
  
  Weaknesses
&lt;/h3&gt;

&lt;p&gt;The cost is ecosystem friction. Tools and packages that were written assuming npm's hoisting behavior will break under pnpm. A &lt;code&gt;postinstall&lt;/code&gt; script that does &lt;code&gt;require('lodash')&lt;/code&gt; without declaring &lt;code&gt;lodash&lt;/code&gt; will fail. Build tools that walk the &lt;code&gt;node_modules&lt;/code&gt; tree looking for specific files may find them in unexpected places because they are symlinked rather than copied. Older packages with complex installs sometimes fail. On Windows, symlinks can introduce permission issues. The Node ecosystem was not built around strict isolation, so opting into pnpm means being prepared for occasional surprises and workarounds.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Lie It Tells
&lt;/h3&gt;

&lt;p&gt;pnpm's lie is that the filesystem representation of dependencies is obvious and complete. The reality is that the filesystem is now abstract: packages are symlinks to a global store, and the actual files are cached globally. You can no longer walk into &lt;code&gt;node_modules&lt;/code&gt; and see what you have; you have to understand symlinks and content addressing. For teams used to simple filesystem navigation, this is a minor lie, but it is there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;

&lt;p&gt;You install a package that has a &lt;code&gt;postinstall&lt;/code&gt; script expecting to reach a transitive dependency. Under npm, the hoisting may make that work accidentally. Under pnpm, the transitive dependency is not in that package's &lt;code&gt;node_modules&lt;/code&gt; tree, so the script fails. You either need to add the transitive dependency to the package's declared dependencies (which is the "correct" fix) or work around it. This kind of friction is common enough to notice during monorepo migrations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Best for
&lt;/h3&gt;

&lt;p&gt;pnpm is the best practical upgrade from npm if your pain point is disk usage or install time in a monorepo, or if you want the strictness of explicit dependencies without the complexity and friction of Yarn's PnP mode. It is increasingly becoming the package manager of choice for large monorepos and organizations that can tolerate the ecosystem friction. For solo projects or teams that prioritize broad compatibility over strictness, pnpm offers less value.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;a href="https://bun.sh/" rel="noopener noreferrer"&gt;Bun&lt;/a&gt;: Speed as a First-Class Citizen
&lt;/h2&gt;

&lt;p&gt;Bun is a newer runtime that bundles its own package manager, and that manager reflects Bun's core philosophy: speed should feel magical, and the entire developer experience should be instantaneous. Unlike npm, Yarn, and pnpm, which are package managers that happen to work with Node, Bun's package manager is designed from the ground up as part of a runtime that understands and optimizes for the entire development loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core Model
&lt;/h3&gt;

&lt;p&gt;Bun's package manager uses a global cache with aggressive deduplication, typically via hardlinks rather than pnpm-style symlink graphs. pnpm's approach is symlink-heavy and isolation-first. Bun's is tuned for fast installs and runtime throughput. The install process is dramatically faster than npm, in part because Bun itself is written in Zig and uses parallelism aggressively, and in part because Bun can resolve and validate dependencies using runtime knowledge that npm cannot. Bun also attempts to maintain broad compatibility with npm's &lt;code&gt;node_modules&lt;/code&gt; layout, so the transition is often smooth, but Bun can also use its own dependency resolution when advantageous.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design Philosophy
&lt;/h3&gt;

&lt;p&gt;Bun's philosophy is simplicity through speed. The core belief is that friction in the development loop comes from waiting—waiting for installs, waiting for builds, waiting for tests. Bun attacks that friction by making every operation as fast as possible, and by consolidating tools that developers usually need to install separately (bundler, transpiler, test runner, package manager) into a single cohesive system. The design accepts some ecosystem incompatibility if that incompatibility enables significant speed gains.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;p&gt;Bun is genuinely fast in ways that matter. Install times are often 5–10x faster than npm, and that speed translates to real developer experience gains, especially in CI pipelines or on machines with slower disks. Running &lt;code&gt;bun install&lt;/code&gt; and then immediately using installed packages feels snappy in a way that npm rarely achieves. Because Bun is a complete runtime, it can also function as a drop-in replacement for Node in many scenarios: you can run TypeScript files directly without transpilation, use Bun's built-in test runner instead of installing Jest, and use Bun's bundler instead of webpack. For greenfield projects or teams willing to commit to Bun as their primary runtime, this all-in-one experience is compelling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Weaknesses
&lt;/h3&gt;

&lt;p&gt;Bun is still maturing, and that maturity gap is visible in production. Some native Node modules do not work with Bun because Bun's native module interface is different from Node's. Complex webpack configurations or advanced build setups sometimes require adaptation. Third-party tools that hook into Node internals (like certain APM or debugging tools) may not work. Bun's adoption is still small compared to Node, so the ecosystem is less tested against Bun semantics. The risk is real: betting on Bun means accepting that you may hit undocumented edge cases or that some dependency may not work as expected. For teams that need guaranteed stability and compatibility across a broad range of tooling, Bun is not yet a safe choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Lie It Tells
&lt;/h3&gt;

&lt;p&gt;Bun's lie is that you can use it as a drop-in replacement for Node without any friction. The reality is that while Bun is compatible with a high percentage of Node packages and tooling, it is not completely compatible. Tools that assume Node internals, native modules with Node-specific bindings, or code that relies on subtle Node.js behavior differences will surface issues that are not immediately obvious.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;

&lt;p&gt;You switch your project to Bun and installs become blazingly fast. Your basic tests run. You deploy and everything works for a few weeks. Then a package that uses a native module breaks because its Node-specific binding does not work in Bun. Or an internal tool relies on &lt;code&gt;node&lt;/code&gt; being available in the PATH, and Bun is not recognized as a drop-in replacement. Or a third-party SDK that patches Node internals fails. These are not Bun issues; they are ecosystem expectations that Bun has not yet fully absorbed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Best for
&lt;/h3&gt;

&lt;p&gt;Bun is best for greenfield projects where you control the entire toolchain and can commit to Bun as your primary runtime. If your primary goal is speed and developer experience, and you are willing to occasionally work around compatibility edge cases, Bun is a compelling choice. For existing projects heavily invested in Node.js tooling, or for production systems that require broad compatibility guarantees, Bun is not yet the pragmatic choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;a href="https://deno.land/" rel="noopener noreferrer"&gt;Deno&lt;/a&gt;: The Secure, Web-Native Alternative
&lt;/h2&gt;

&lt;p&gt;Deno is a runtime built by the creator of Node, and its package manager reflects a philosophical rethinking of what dependency management should mean on the web. Its design starts from URL-native imports and a global cache model, but modern Deno also provides strong npm interoperability for teams that need Node-style workflows. This hybrid approach is still meaningfully different from the defaults in npm, Yarn, pnpm, and Bun, and it surfaces tradeoffs that appeal to developers who care about security and clean architecture more than ecosystem inertia.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core Model
&lt;/h3&gt;

&lt;p&gt;In modern Deno, you can import npm packages directly with &lt;code&gt;npm:&lt;/code&gt; specifiers (for example &lt;code&gt;import chalk from "npm:chalk@5"&lt;/code&gt;) and map clean bare specifiers via &lt;code&gt;deno.json&lt;/code&gt;/&lt;code&gt;deno.jsonc&lt;/code&gt; import maps. The runtime still uses a global cache as its primary model, but for Node compatibility workflows it can also materialize a &lt;code&gt;node_modules&lt;/code&gt; directory when needed. In that mode, layout behavior is configurable rather than fixed, so teams can choose conventions that are closer to isolated or hoisted dependency trees depending on interoperability needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design Philosophy
&lt;/h3&gt;

&lt;p&gt;Deno's philosophy is security by default and simplicity through URLs. The core belief is that dependencies should be explicit and traceable, and that the web's native import model (URLs) is cleaner and more secure than npm's node_modules. Security is not an afterthought; Deno grants zero permissions by default. A script cannot access the network, file system, or environment variables without explicit permission flags. This is a radical departure from Node, where every installed package can do anything to your system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strengths
&lt;/h3&gt;

&lt;p&gt;Deno's security model is genuinely compelling. You know exactly what URLs you are importing from, and you can audit them. Third-party code cannot access your file system or make network calls unless you explicitly allow it with permission flags. The built-in toolchain is also excellent: Deno includes a formatter, linter, test runner, documentation generator, and bundler without needing additional installations. For projects starting from scratch with TypeScript, Deno feels clean and cohesive in a way that Node does not. URL-based imports can also reduce registry coupling and make dependency provenance clearer in workflows that use them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Weaknesses
&lt;/h3&gt;

&lt;p&gt;Deno is smaller than Node and npm, so the ecosystem is still narrower in practice even with strong npm interoperability. While many npm packages now run well through Deno's compatibility layer, edge cases remain around tooling assumptions, native addons, and deeply Node-specific behavior in older dependencies. Import maps and &lt;code&gt;npm:&lt;/code&gt; specifiers reduce migration friction substantially, but they do not eliminate all compatibility work for mature codebases with complex build pipelines. For teams deeply embedded in Node-specific tooling, Deno can still feel like a step sideways before it feels like a step forward.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Lie It Tells
&lt;/h3&gt;

&lt;p&gt;Deno's lie is that teams treat its compatibility story as a migration shortcut. In practice, Deno gives you several valid models at once (URL imports, &lt;code&gt;npm:&lt;/code&gt; imports, import maps, optional &lt;code&gt;node_modules&lt;/code&gt;), and that flexibility creates architectural decisions teams still need to standardize. Security defaults and cleaner primitives help, but dependency policy, version governance, and ecosystem fit checks are still real work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example
&lt;/h3&gt;

&lt;p&gt;You start a new Deno project and mix &lt;code&gt;npm:&lt;/code&gt; imports with import-map aliases, so local development feels close to Node while still keeping Deno's runtime defaults. It works well for most dependencies. Later, your team adds tooling that assumes a specific &lt;code&gt;node_modules&lt;/code&gt; layout and hits subtle integration issues in CI until you align configuration and conventions across repos. The lesson is not that Deno is incompatible; it is that flexibility needs explicit team standards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Best for
&lt;/h3&gt;

&lt;p&gt;Deno is best for security-conscious projects, fresh TypeScript greenfield work, and teams that philosophically prefer the web's native import model. If you are building backend services or tooling where the built-in security model matters, and you are willing to accept a smaller ecosystem, Deno is a compelling choice. For projects that rely heavily on npm packages or for teams that need maximum ecosystem access, Deno is not yet pragmatic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Thesis: Different Mental Models
&lt;/h2&gt;

&lt;p&gt;When we step back from each tool's implementation details, a clearer pattern emerges. The real axis of difference is not speed or disk usage or which tool claims to be the fastest. The real difference is how each tool chooses to represent and resolve dependencies in the first place, and what that representation means about the relationship between your code and the packages it depends on.&lt;/p&gt;

&lt;p&gt;At the heart of these five tools is one fundamental question: how should the package manager resolve and physically (or virtually) lay out dependencies on your machine? npm flattens them to maximize compatibility. Yarn reproducibly locks them and can abstract them away entirely. pnpm isolates them structurally. Bun optimizes around speed. Deno rejects the node_modules model altogether. Each answer reflects a different assumption about what dependencies should be, and each assumption carries consequences.&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;Mental Model&lt;/th&gt;
&lt;th&gt;Core Assumption&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;npm&lt;/td&gt;
&lt;td&gt;Flattened convenience graph&lt;/td&gt;
&lt;td&gt;Compatibility first&lt;/td&gt;
&lt;td&gt;Most everyday projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yarn&lt;/td&gt;
&lt;td&gt;Reproducible build artifact&lt;/td&gt;
&lt;td&gt;Tooling extensibility&lt;/td&gt;
&lt;td&gt;Enterprise customization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pnpm&lt;/td&gt;
&lt;td&gt;Explicit isolated graph&lt;/td&gt;
&lt;td&gt;Structural correctness&lt;/td&gt;
&lt;td&gt;Monorepos &amp;amp; large codebases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bun&lt;/td&gt;
&lt;td&gt;Invisible high-performance detail&lt;/td&gt;
&lt;td&gt;Speed should feel magical&lt;/td&gt;
&lt;td&gt;Speed-first greenfield&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deno&lt;/td&gt;
&lt;td&gt;URL-native + npm-compatible hybrid&lt;/td&gt;
&lt;td&gt;Security-first defaults + interoperability&lt;/td&gt;
&lt;td&gt;Security / philosophy-driven work&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where Things Actually Break
&lt;/h2&gt;

&lt;p&gt;Understanding these mental models is intellectually interesting, but the gap between philosophy and practice is where package managers reveal themselves. The Node.js ecosystem was built entirely around npm's assumptions. Thousands of packages, build tools, and deployment scripts are hardcoded to expect npm's specific model of dependency resolution and layout. Any deviation from that model carries a compatibility tax, and that tax is paid in small, accumulated frictions that add up.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I once spent four hours debugging a monorepo migration from npm to pnpm. The overall migrate looked clean: update the lockfile, run &lt;code&gt;pnpm install&lt;/code&gt;, commit, done. But a postinstall script deep in one of our dependencies was doing something that should have been impossible: it was reaching into a transitive dependency that pnpm didn't hoist by default. The script didn't declare that dependency, so it shouldn't have been able to find it. Under npm's flattened model, it was just there. Under pnpm's strict model, it was nowhere. The build broke silently at a step the script didn't fail on, it just couldn't find what it needed. Debugging required understanding not just what pnpm did differently, but what that dependency's script was secretly assuming about npm's layout.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Switching Feels Like Progress (or Regress)
&lt;/h2&gt;

&lt;p&gt;Every few years, a new wave of developers discovers a newer package manager and adopts it with missionary zeal. npm → Yarn felt revolutionary in 2016 because npm was genuinely unstable and Yarn's lockfile was a genuine breakthrough. npm → pnpm often feels like finally getting it right, because pnpm's strict isolation catches real bugs that npm hides. npm → Bun feels like magic because it &lt;em&gt;is&lt;/em&gt; fast. npm → Deno feels philosophically cleaner because security-by-default and URL-based imports genuinely reduce certain classes of risk.&lt;/p&gt;

&lt;p&gt;And yet, for most day-to-day work, staying on npm still feels like the rational default. That is not because npm is objectively best, adoption is rarely driven by just technical superiority in isolation. It's driven by the difference between friction and payoff. For a small team on a stable project with no monorepo pain, the chore of switching to pnpm outweighs the payoff. For a team that has just hit their third incident caused by a phantom dependency, pnpm suddenly looks very attractive. For a greenfield project where speed is critical and you can control the entire toolchain, Bun is worth the risk. But most teams inherit their package manager from whatever was already there when they joined.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Decision Guidance in 2026
&lt;/h2&gt;

&lt;p&gt;Choosing a package manager should be boring and practical, not philosophical. Here is how to think about it:&lt;/p&gt;

&lt;p&gt;If you are starting a new project or working on something small, solo, or legacy where nothing breaks and you want zero friction, &lt;strong&gt;npm&lt;/strong&gt; is still the right choice. It is the default, it works, and your entire team already understands it. If you have a monorepo with hundreds of packages and your CI pipeline is slow or your developers are regularly confused about which dependencies are actually available, &lt;strong&gt;pnpm&lt;/strong&gt; is the strongest practical upgrade. It solves real pain points without requiring architectural rethinking. If you have a large enterprise with heavy custom workflows, heavy internal tooling requirements, or deep build customization, &lt;strong&gt;Yarn Berry&lt;/strong&gt; can provide the plugin system and flexibility you need. If you are starting a new greenfield project and your primary constraint is speed—and you are willing to occasionally work around compatibility edges, &lt;strong&gt;Bun&lt;/strong&gt; offers a genuinely better developer experience. If you are building something security-critical or you are philosophically committed to clean, traceable dependencies, &lt;strong&gt;Deno&lt;/strong&gt; is worth the ecosystem cost.&lt;/p&gt;

&lt;p&gt;The truth is that most teams do not actually choose their package manager, they inherit one. The developer who set up the project chose it three years ago, and now changing it feels like choosing to fight an unnecessary battle. That inertia is not always wrong. Switching has real costs, and the payoff is often smaller than it feels until you have actually experienced years of pain under your current tool.&lt;/p&gt;

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

&lt;p&gt;Your package manager is not malicious. It's not lying out of deception, it's lying out of necessity. Every tool optimizes for a specific set of assumptions about what &lt;code&gt;node_modules&lt;/code&gt; should represent, what dependencies should mean, and what the developer's priority actually is. npm optimizes for compatibility and zero configuration. Yarn optimizes for reproducibility and extensibility. pnpm optimizes for correctness and disk efficiency. Bun optimizes for speed. Deno optimizes for security and simplicity on the web.&lt;/p&gt;

&lt;p&gt;The real question you need to ask yourself is not "which package manager is best?" but "which set of assumptions, and which set of lies, am I willing to live with?" Because every tool has tradeoffs: they make a fundamental choice about what matters and what you can afford to sacrifice. Understanding those choices, and understanding what your real pain point actually is, is the only way to make a decision you will not regret.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Evaluate an npm Package - 2026 Edition</title>
      <dc:creator>Gabor Koos</dc:creator>
      <pubDate>Thu, 11 Jun 2026 02:11:05 +0000</pubDate>
      <link>https://dev.to/gkoos/how-to-evaluate-an-npm-package-2026-edition-e8j</link>
      <guid>https://dev.to/gkoos/how-to-evaluate-an-npm-package-2026-edition-e8j</guid>
      <description>&lt;p&gt;Every time you run &lt;code&gt;npm install&lt;/code&gt;, you are adding code that will execute in your production environment: code written by someone you have never met, with access to whatever your process can reach. It might touch your filesystem, make outbound network requests, read environment variables, or quietly exfiltrate data. You are, in effect, trusting a stranger with your infrastructure.&lt;/p&gt;

&lt;p&gt;Most developers manage this risk by checking two numbers: weekly downloads and GitHub stars. Neither tells you anything meaningful about whether a package is safe, maintained, or honest about what it does. (Most npm packages use GitHub. If a project is hosted elsewhere, apply the same principles.)&lt;/p&gt;

&lt;p&gt;Supply chain attacks have made this worse. Event-stream, ua-parser-js, node-ipc, xz utils - the pattern is consistent: a legitimate, widely used package gets compromised, either through a maintainer being social-engineered, a typosquat, or a dependency buried three levels deep. The npm ecosystem, with its culture of small composable packages and deep transitive dependency trees, is a particularly attractive target. You can do everything right and still get hit through something you never directly installed.&lt;/p&gt;

&lt;p&gt;There is a newer variation worth knowing about. AI coding assistants hallucinate package names. They confidently suggest &lt;code&gt;npm install some-plausible-sounding-package&lt;/code&gt; for packages that do not exist. Attackers monitor those hallucinations and register the names - a technique now called &lt;strong&gt;slopsquatting&lt;/strong&gt; - so that when a developer follows the suggestion without checking, they install something malicious. If an LLM suggests a package you have never heard of, verify that it exists, has a real history, and has provenance before you run the install.&lt;/p&gt;

&lt;p&gt;This risk increases when you run LLMs in agent mode. In many setups, package selection and installation happen back-to-back without a human checkpoint. If you are doing that, rely on enforcement in the toolchain (for example, a wrapper or install hook that blocks unknown or unverified packages), not on "remember to check first" prompt text.&lt;/p&gt;

&lt;p&gt;None of the above means you should stop using open source packages - that would make you less productive without making you meaningfully safer. What it means is that picking a package deserves more than a five-second glance at the star count.&lt;/p&gt;

&lt;p&gt;This guide gives you a repeatable process for evaluating an npm package before you add it. It takes 5 to 10 minutes. It won't guarantee safety - nothing will - but it will help you make an informed decision rather than an optimistic one.&lt;/p&gt;

&lt;h2&gt;
  
  
  0. Do you actually need this package?
&lt;/h2&gt;

&lt;p&gt;Before you audit anything, ask the simplest question first: should this dependency exist in your project at all?&lt;/p&gt;

&lt;p&gt;Many npm incidents become severe not because one package is inherently catastrophic, but because a tiny convenience package gets copied across dozens of services and frontend apps until it is everywhere. If that package is compromised, abandoned, or suddenly unpublished, your blast radius is no longer small.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to check:
&lt;/h3&gt;

&lt;p&gt;Do a &lt;strong&gt;removal test&lt;/strong&gt; in your head: if this package disappeared tomorrow, how hard would it be to replace? If the answer is "we would have to refactor half the codebase," treat it as high-risk and apply stricter scrutiny.&lt;/p&gt;

&lt;p&gt;If it is a tiny utility, ask whether you can implement the same thing in a few lines in your own codebase. Pulling a dependency for one helper function is often not worth the long-term risk.&lt;/p&gt;

&lt;p&gt;Check the package dependency footprint. A package with zero runtime dependencies is not automatically safe, but fewer dependencies generally means a smaller attack surface and fewer transitive surprises. On npm, inspect the dependency count and scan what those dependencies actually are.&lt;/p&gt;

&lt;p&gt;Then check where you plan to use it. A package used in one isolated internal tool has a different risk profile from one that will be imported across every service.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Is it actively maintained?
&lt;/h2&gt;

&lt;p&gt;An unmaintained package is a liability that compounds over time. Security vulnerabilities go unpatched. Compatibility with newer Node versions breaks silently. The API freezes while the ecosystem moves on, and eventually you are pinned to an old version of something because updating it would require replacing a package nobody is touching anymore.&lt;/p&gt;

&lt;p&gt;The obvious signal is recent commits, but commit frequency alone is misleading. A package can have a commit last week that does nothing but update a CI action. What you are looking for is whether the author is still engaged with the actual software.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to check:
&lt;/h3&gt;

&lt;p&gt;On the GitHub repository, go to the &lt;strong&gt;Issues&lt;/strong&gt; tab. Look at the oldest open issues. Are they acknowledged? If someone reported a bug 18 months ago and the author has never replied, that tells you something about what the maintenance relationship looks like when things go wrong.&lt;/p&gt;

&lt;p&gt;Look at the &lt;strong&gt;Commits&lt;/strong&gt; tab. Filter out bot commits and CI noise. When was the last time a human made a meaningful change to the source code, not just a dependency bump or a workflow tweak?&lt;/p&gt;

&lt;p&gt;Look at who is doing the work. If almost every meaningful commit, release, and issue reply comes from one person, you have &lt;strong&gt;maintainer concentration risk&lt;/strong&gt; (the "bus factor" - the number of people who would need to be hit by a bus before the project is in trouble). That is not automatically bad - many excellent packages are run by one maintainer - but it means your operational risk is tied to one human's availability and energy.&lt;/p&gt;

&lt;p&gt;On the &lt;strong&gt;npm page&lt;/strong&gt;, go to the Versions tab. Is there a recognizable release cadence - monthly, quarterly, whatever - or a 2-year gap followed by a burst of activity? Long gaps followed by sudden updates are sometimes a red flag in themselves: accounts do get taken over.&lt;/p&gt;

&lt;p&gt;Check the &lt;strong&gt;CHANGELOG&lt;/strong&gt;. If it just lists commit hashes, it is nearly useless. A changelog that says "Fixed: deduplication plugin now clones responses per waiter to prevent body-already-used errors" is a changelog written by someone who cares whether you understand what changed and why. The quality of the changelog is a proxy for how the author thinks about the people using their software.&lt;/p&gt;

&lt;p&gt;Finally, if the package exposes a public API that has changed over time, look for a migration guide. An author who documents breaking changes and provides an upgrade path is an author who thinks about the downstream cost of their decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Can you trust what's actually published to npm?
&lt;/h2&gt;

&lt;p&gt;Maintenance is about the future. This section is about something more immediate: whether what is on the npm registry right now is actually what the author intended to publish.&lt;/p&gt;

&lt;p&gt;The npm registry has no verification by default. When you install a package, you are trusting that the bytes you receive match the source code you can read on GitHub. For most packages, most of the time, that is true. But the mechanism that links source to publish - an &lt;code&gt;NPM_TOKEN&lt;/code&gt; stored as a CI secret, or sometimes just on a developer's laptop - is exactly the kind of credential that attackers target.&lt;/p&gt;

&lt;p&gt;The event-stream attack in 2018 is the clearest example: a maintainer handed over control of a popular package to a stranger. The stranger published a version with a malicious dependency. Millions of projects were affected before anyone noticed. No one hacked GitHub. No one broke npm's infrastructure. They just got the credentials.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.npmjs.com/cli/v9/commands/npm-publish#provenance" rel="noopener noreferrer"&gt;&lt;strong&gt;Provenance attestation&lt;/strong&gt;&lt;/a&gt; is the modern answer to this. When a package is published with provenance, the npm registry receives a cryptographic attestation (signed by GitHub's OIDC infrastructure) that ties the specific package tarball to a specific commit in a specific repository, built by a specific GitHub Actions workflow run. You can verify it. The attestation is public. If someone publishes a package claiming to be from a specific repository but the attestation does not match, that is detectable.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to check:
&lt;/h3&gt;

&lt;p&gt;Go to &lt;code&gt;npmjs.com/package/&amp;lt;package-name&amp;gt;&lt;/code&gt;. Next to the version name, there should be a green "Provenance" badge if the package was published with provenance:&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%2Fk2j5yukekmrdecrey7jr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk2j5yukekmrdecrey7jr.png" alt="Provenance badge" width="366" height="79"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Click on it, then on the "View more details" link. It will take you to the bottom of the page:&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%2Feq5itsyz7x7xjw9dy5mh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feq5itsyz7x7xjw9dy5mh.png" alt="Provenance details" width="799" height="169"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It shows the repository URL, the commit SHA, and the GitHub Actions workflow run that published the package. You can click through to all of those things to verify that they exist and make sense. If the badge is there and the details check out, you can be reasonably confident that the package you are installing is what the author intended to publish.&lt;/p&gt;

&lt;p&gt;If it is not there, the package was published without provenance - which is not automatically suspicious (most packages predate the feature), but it means you cannot verify the source-to-publish chain.&lt;/p&gt;

&lt;p&gt;You can also check from the command line. In any project that has the package installed, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm audit signatures
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This verifies the cryptographic signatures of all installed packages and reports which ones have valid provenance attestation.&lt;/p&gt;

&lt;p&gt;If you want to look at how a package is published, find the &lt;code&gt;.github/workflows/&lt;/code&gt; directory in the repository and open the publish workflow. Look for three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;npm publish --provenance&lt;/code&gt; - this is what generates the attestation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;id-token: write&lt;/code&gt; in the job permissions - this is what allows the OIDC token exchange with npm&lt;/li&gt;
&lt;li&gt;The absence of &lt;code&gt;NPM_TOKEN&lt;/code&gt; as a secret - if the workflow uses Trusted Publishing (OIDC), there is no long-lived token to steal&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Finally, look at how GitHub Actions are referenced in the workflow files. Actions referenced as &lt;code&gt;uses: actions/checkout@v4&lt;/code&gt; or &lt;code&gt;uses: actions/setup-node@main&lt;/code&gt; are pinned to a mutable tag - the action author can change what that tag points to at any time, and your workflow will silently start running different code. Actions pinned to a full commit SHA (&lt;code&gt;uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd&lt;/code&gt;) cannot be changed without updating the reference in the workflow file itself. It is a small thing, but it closes a real attack surface.&lt;/p&gt;

&lt;p&gt;One more thing that runs code on your machine before you have reviewed any of it: install scripts. The &lt;code&gt;preinstall&lt;/code&gt;, &lt;code&gt;install&lt;/code&gt;, and &lt;code&gt;postinstall&lt;/code&gt; hooks in &lt;code&gt;package.json&lt;/code&gt; execute the moment you run &lt;code&gt;npm install&lt;/code&gt;. Most legitimate packages do not need them - native addons that compile C++ bindings are the main genuine use case. If you see an install script in a package that has no obvious reason for one, that is worth understanding before you proceed.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Is the CI pipeline real or decorative?
&lt;/h2&gt;

&lt;p&gt;A green badge in the README is easy to fake, or rather, easy to earn without it meaning much. A repository can have continuous integration that runs three tests on a single happy path and reports 100% pass rate. That badge is technically accurate and entirely useless.&lt;/p&gt;

&lt;p&gt;What you want to know is whether the CI pipeline actually protects the codebase: whether it would catch a regression, a type error, or a broken edge case before it ships.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to check:
&lt;/h3&gt;

&lt;p&gt;Go to the &lt;strong&gt;Actions&lt;/strong&gt; tab on GitHub. Look at the workflow runs. Do they trigger on &lt;code&gt;pull_request&lt;/code&gt; events, not just on pushes to &lt;code&gt;main&lt;/code&gt;? A pipeline that only runs after merging is not protecting anything, it's just producing a record of what already happened.&lt;/p&gt;

&lt;p&gt;Open a recent merged pull request. Did CI run on it? Did anyone have to wait for it to pass before merging? If the PR was merged 30 seconds after it was opened with no CI run, the pipeline is decoration.&lt;/p&gt;

&lt;p&gt;Find the test configuration file: &lt;code&gt;vitest.config.js&lt;/code&gt;, &lt;code&gt;jest.config.js&lt;/code&gt;, or equivalent. Look for coverage thresholds. Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;thresholds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;branches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If thresholds are configured, the CI pipeline will fail if coverage drops below them. If they are not configured, coverage might be reported but it is not enforced, an author can delete half the tests and the build will still pass.&lt;/p&gt;

&lt;p&gt;Also look at what the tests actually cover. A &lt;code&gt;test/&lt;/code&gt; directory that mirrors the &lt;code&gt;src/&lt;/code&gt; structure, or unit tests co-located with the source files, is a good sign. A single &lt;code&gt;index.test.ts&lt;/code&gt; file with a handful of smoke tests is a different thing entirely. You cannot audit the tests in detail, but you can get a sense of whether the author takes them seriously.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Is the code quality visible?
&lt;/h2&gt;

&lt;p&gt;You are not going to audit the entire codebase of every package you consider. That is not realistic. But you can take a quick look at the signals that correlate with code quality, the things an author does or does not do that are visible at a glance and tend to predict whether the package is robust or fragile.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to check:
&lt;/h3&gt;

&lt;p&gt;Look at the linting configuration: &lt;code&gt;eslint.config.js&lt;/code&gt;, &lt;code&gt;.eslintrc&lt;/code&gt;, or equivalent. Does it exist? Is it non-trivial? A blank or near-empty linting config suggests the author is not enforcing consistency or catching obvious mistakes automatically. Linting is table stakes; its absence is a signal.&lt;/p&gt;

&lt;p&gt;Check whether the package ships a well-formed bundle. Look at the &lt;code&gt;exports&lt;/code&gt; field in &lt;code&gt;package.json&lt;/code&gt;. A modern package should specify named export conditions: &lt;code&gt;import&lt;/code&gt; and &lt;code&gt;require&lt;/code&gt; if it supports both module systems, or just &lt;code&gt;import&lt;/code&gt; if it is intentionally ESM-only, and ideally &lt;code&gt;types&lt;/code&gt; if it ships TypeScript declarations. A package that only sets &lt;code&gt;main&lt;/code&gt; with no &lt;code&gt;exports&lt;/code&gt; field at all was written before this became standard practice. That is not disqualifying, but it tells you something about how current the author's practice is.&lt;/p&gt;

&lt;p&gt;Look at the &lt;code&gt;package.json&lt;/code&gt; for a &lt;code&gt;prepublishOnly&lt;/code&gt; script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"prepublishOnly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run build &amp;amp;&amp;amp; npm run test:ci"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents an author from accidentally publishing a broken build or a build that skips tests. It does not protect against malicious publishes, but it does tell you the author has thought about accidental ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For TypeScript packages specifically:&lt;/strong&gt; open &lt;code&gt;tsconfig.json&lt;/code&gt; and check whether &lt;code&gt;strict: true&lt;/code&gt; is set. Strict mode enables null checks, strict function types, and no-implicit-any - a whole class of bugs caught at compile time rather than in production. An author who turns it off has decided some of those bugs are acceptable.&lt;/p&gt;

&lt;p&gt;Also search the repository for &lt;code&gt;any&lt;/code&gt; and &lt;code&gt;@ts-ignore&lt;/code&gt;. A few uses in genuinely awkward interop situations is normal. Dozens of them scattered through the source code means the TypeScript types are largely cosmetic: the package has the &lt;code&gt;.ts&lt;/code&gt; extension but not the type safety.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What happens when something goes wrong?
&lt;/h2&gt;

&lt;p&gt;Every non-trivial package will eventually have a security vulnerability. The question is how the author handles it when it does. Do they respond quickly? Do they disclose responsibly - privately first, then publicly with a fix? Do they document what happened so you can assess whether your version is affected?&lt;/p&gt;

&lt;h3&gt;
  
  
  What to check:
&lt;/h3&gt;

&lt;p&gt;Look for a &lt;code&gt;SECURITY.md&lt;/code&gt; file in the repository root, or go to &lt;code&gt;github.com/&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;/security/policy&lt;/code&gt;. This should tell you how to report a vulnerability privately, a contact method, a timeline for response, and some indication that the author takes disclosures seriously. Its absence does not mean the package is insecure, but it does mean that if you find something, you have no clear path to report it without accidentally disclosing it publicly.&lt;/p&gt;

&lt;p&gt;Go to the &lt;strong&gt;Security&lt;/strong&gt; tab on GitHub and look at the Advisories section. Has anything been published? If yes, how was it handled - was the disclosure coordinated, was a fix available when it went public, was the affected version range clearly documented? A well-handled historical advisory is actually a positive signal; it means the author knows what a responsible disclosure process looks like and has followed it.&lt;/p&gt;

&lt;p&gt;Check &lt;code&gt;osv.dev&lt;/code&gt; or &lt;code&gt;snyk.io&lt;/code&gt; for known vulnerabilities in the package. These aggregate CVEs and GitHub Security Advisories. If the package has known unpatched vulnerabilities, that is something you need to know before installing it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://socket.dev" rel="noopener noreferrer"&gt;Socket.dev&lt;/a&gt; goes further than CVE databases. Rather than waiting for a vulnerability to be reported and cataloged, it does behavioral analysis: does this package access the network? Does it touch the filesystem in unexpected ways? Does it contain obfuscated code? Does it have install scripts? It also has a GitHub app that runs this analysis on pull requests and flags new dependencies before they are merged. For packages you are seriously considering, it is worth a quick check.&lt;/p&gt;

&lt;p&gt;If you operate in a high-assurance environment, &lt;a href="https://socket.dev/features/firewall" rel="noopener noreferrer"&gt;Socket Firewall&lt;/a&gt; is also worth knowing about. Instead of only warning at review time, it enforces package policy at install time and can block known-malicious packages from entering your environment at all. That is usually overkill for hobby projects, but for regulated or security-sensitive systems it can be a strong extra control.&lt;/p&gt;

&lt;p&gt;Finally, look at how quickly past security issues were addressed. A critical vulnerability fixed within days is different from one that sat open for three months. Response time on security issues is one of the clearest signals of how seriously an author takes the responsibility of maintaining a public package.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Checklist
&lt;/h2&gt;

&lt;p&gt;Not all of these checks carry equal weight. If you only have time for three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do you actually need it?&lt;/strong&gt; If you can replace it quickly or avoid it entirely, you remove risk instead of managing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it have provenance?&lt;/strong&gt; This is the clearest signal that the published package is what the author intended to publish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does it have unexplained install scripts?&lt;/strong&gt; Code runs on your machine the moment you run &lt;code&gt;npm install&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the package is production-critical, add a fourth check: &lt;strong&gt;is the maintainer responsive?&lt;/strong&gt; If something goes wrong, you want to know that the person who can fix it will actually fix it.&lt;/p&gt;

&lt;p&gt;Everything else is worth checking when the stakes are higher.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security-critical signals
&lt;/h3&gt;

&lt;p&gt;These affect whether the package is safe to install and whether what you are installing is what the author published.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Where to check&lt;/th&gt;
&lt;th&gt;Green&lt;/th&gt;
&lt;th&gt;Red&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Provenance&lt;/td&gt;
&lt;td&gt;npmjs.com version page&lt;/td&gt;
&lt;td&gt;"Provenance" section present and matches repo&lt;/td&gt;
&lt;td&gt;No provenance, or version published from unknown source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trusted publishing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.github/workflows/publish.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OIDC + &lt;code&gt;--provenance&lt;/code&gt;, no &lt;code&gt;NPM_TOKEN&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NPM_TOKEN&lt;/code&gt; secret, manual publish steps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install scripts&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;package.json&lt;/code&gt; scripts field&lt;/td&gt;
&lt;td&gt;None present, or obvious native-addon reason&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;preinstall&lt;/code&gt;/&lt;code&gt;postinstall&lt;/code&gt; with no clear justification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pinned CI actions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.github/workflows/*.yml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SHA-pinned third-party actions&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@v3&lt;/code&gt;, &lt;code&gt;@latest&lt;/code&gt;, &lt;code&gt;@main&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Operational maturity signals
&lt;/h3&gt;

&lt;p&gt;These tell you how seriously the author takes maintenance, quality, and the long-term cost of depending on their package.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Where to check&lt;/th&gt;
&lt;th&gt;Green&lt;/th&gt;
&lt;th&gt;Red&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Active maintenance&lt;/td&gt;
&lt;td&gt;GitHub commits + open issues&lt;/td&gt;
&lt;td&gt;Commits in last 3 months, issues acknowledged&lt;/td&gt;
&lt;td&gt;Last commit 2+ years ago, stale issues ignored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependency footprint&lt;/td&gt;
&lt;td&gt;npm package page + lockfile tree&lt;/td&gt;
&lt;td&gt;Few dependencies for package scope, no surprising transitive tree&lt;/td&gt;
&lt;td&gt;Large transitive tree for a trivial utility&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintainer concentration&lt;/td&gt;
&lt;td&gt;Commits, releases, issue/PR responses&lt;/td&gt;
&lt;td&gt;Work distributed across multiple active maintainers&lt;/td&gt;
&lt;td&gt;One maintainer handles nearly all code, releases, and support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coverage enforced&lt;/td&gt;
&lt;td&gt;vitest/jest config&lt;/td&gt;
&lt;td&gt;Thresholds configured at 80%+&lt;/td&gt;
&lt;td&gt;No thresholds, or coverage badge that never changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security policy&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SECURITY.md&lt;/code&gt; or Security tab&lt;/td&gt;
&lt;td&gt;Clear disclosure process, contact method&lt;/td&gt;
&lt;td&gt;Missing, or just a generic template with no contact&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A package that is clean on security-critical signals but weak on operational maturity is a calculated risk. A package that fails the security-critical signals is a different category of problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Risk is not binary
&lt;/h2&gt;

&lt;p&gt;None of this is a pass/fail test. It is a risk assessment, and risk depends on context.&lt;/p&gt;

&lt;p&gt;A utility that formats dates in a UI has a different risk profile than an HTTP client sitting between your service and a government API. A dependency that receives 50 million weekly downloads has more eyes on it than one that receives 500. A package from an organization with a dedicated security team is different from one maintained by a single developer in their spare time. None of these are disqualifying conditions on their own - some of the most reliable packages in the ecosystem are maintained by individuals - but they affect how much scrutiny you should apply.&lt;/p&gt;

&lt;p&gt;The goal is not to find a package with a perfect score on every dimension. The goal is to understand what you are accepting when you add it to your project, and to make that decision deliberately rather than by default.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>npm</category>
      <category>typescript</category>
    </item>
    <item>
      <title>How I Built a Confluence Crawler</title>
      <dc:creator>Gabor Koos</dc:creator>
      <pubDate>Fri, 22 May 2026 22:37:58 +0000</pubDate>
      <link>https://dev.to/gkoos/how-i-built-a-confluence-crawler-3d26</link>
      <guid>https://dev.to/gkoos/how-i-built-a-confluence-crawler-3d26</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;TLDR: If you are not interested in the story and just want the tool, go straight to the repository: &lt;a href="https://github.com/gkoos/confluence2md" rel="noopener noreferrer"&gt;github.com/gkoos/confluence2md&lt;/a&gt;. It is a CLI that crawls Confluence and mirrors it into local Markdown files, including links, comments, and attachments, with support for incremental updates.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This started with a small problem that slowly became expensive: finding things in our company Confluence was harder than it should have been.&lt;/p&gt;

&lt;p&gt;The first issue was human. Search results were noisy; useful pages existed, but they were buried under stale docs, half-duplicated runbooks, and pages whose titles made sense only to the person who wrote them three years ago. When you are in the middle of an incident or trying to understand a legacy service, that's not ideal.&lt;/p&gt;

&lt;p&gt;The second issue was machine. I was building workflows with LLMs, and Confluence turned out to be a difficult source to work with directly. The content model is not designed around LLM retrieval quality. You can pull pages through APIs, but you still need to normalize structure, preserve context, handle references, and keep updates in sync. If the source layer is messy, everything downstream in your AI pipeline inherits the mess.&lt;/p&gt;

&lt;p&gt;At some point the idea clicked: Markdown is a format that both humans and machines handle well. Humans can read it in any editor, Git can diff it, indexers can process it, LLM pipelines can chunk it. So instead of fighting Confluence at query time, why not mirror the space locally into clean Markdown and treat that mirror as the canonical retrieval layer?&lt;/p&gt;

&lt;p&gt;That was the origin of &lt;code&gt;confluence2md&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Attempt
&lt;/h2&gt;

&lt;p&gt;The first design looked almost too clean: at heart, this is a two-phase crawling problem.&lt;/p&gt;

&lt;p&gt;Phase one: start from one or more seed pages, crawl linked pages to a configurable depth, and convert each page into Markdown.&lt;/p&gt;

&lt;p&gt;Phase two: once you know the complete set of crawled pages, rewrite internal Confluence links into local relative links.&lt;/p&gt;

&lt;p&gt;On paper, this gives you exactly what you want. You avoid guessing link targets while crawling, because rewrite decisions happen only after the graph is known. You keep the logic separable: fetching and conversion in phase one, graph-aware link correction in phase two.&lt;/p&gt;

&lt;p&gt;I expected most of the effort to be around traversal performance and retry logic. Instead, the hardest work appeared in the conversion layer and update model. The crawling algorithm was the easy part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pain of Converting Confluence to Markdown
&lt;/h2&gt;

&lt;p&gt;Confluence content is not "almost Markdown": it is stored in a custom storage format that is XML-heavy, macro-heavy, and full of edge cases that are perfectly valid in Confluence but awkward outside of it.&lt;/p&gt;

&lt;p&gt;The first surprise: two pages that look visually similar in Confluence can serialize very differently in storage format. Tables, rich text blocks, code snippets, callouts, and macro output can appear in patterns that are not trivial to flatten into readable Markdown.&lt;/p&gt;

&lt;p&gt;The second surprise was links: "a link to another Confluence page" is not a single thing. You encounter multiple URL shapes, embedded references, path-based forms, query-based forms, and links whose targets are obvious to Confluence but not obvious to your converter. If link extraction fails silently, your local mirror is technically present but functionally broken.&lt;/p&gt;

&lt;p&gt;The third surprise was macros. Macros are one of Confluence's superpowers, but also one of the biggest export headaches. Some macros map cleanly to Markdown - like constructs. Others are effectively mini-apps embedded in page content. You need a strategy for graceful degradation, not perfect one-to-one fidelity. Your realistic goal is utility, not pixel-perfect cloning.&lt;/p&gt;

&lt;p&gt;The key lesson was that conversion is not just a renderer problem. You are trying to preserve meaning and navigability under a different representation model. Once I accepted that, the implementation got better: normalize aggressively, preserve critical references, and be explicit about what gets transformed versus passed through. And I most likely missed lots of edge cases too. The best I could do was to cover the most common patterns I encountered and make sure the system is resilient to weird content rather than brittle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pain of Comments and Attachments
&lt;/h2&gt;

&lt;p&gt;After getting page conversion to a usable state, comments and attachments became the next wall.&lt;/p&gt;

&lt;p&gt;Comments matter more than people think. In many organizations, the real decision history lives in comments: caveats, corrections, "do not do this anymore", and contextual notes that never made it into the page body. Exporting pages without comments creates a technically complete mirror that is practically incomplete.&lt;/p&gt;

&lt;p&gt;Attachments are similar. A runbook that references scripts, screenshots, or PDFs is only useful if those references survive locally. Broken attachment links are almost worse than missing files, because they create false confidence.&lt;/p&gt;

&lt;p&gt;In Confluence APIs, comments and attachments often come through different endpoints and different response shapes. They do not naturally slot into a naive page conversion pass. I had to treat them as first-class parts of the model: fetch, normalize, persist, and rewrite references so local files resolve correctly.&lt;/p&gt;

&lt;p&gt;For comments, the practical choice was to append them into a clear section in each generated Markdown page. That keeps context colocated with the source page while staying machine-readable. For attachments, the rule was simple: if a page references a file and that file is in scope, download it and rewrite the reference to local path. If not, fail visibly.&lt;/p&gt;

&lt;p&gt;Once those rules were in place, the mirror stopped feeling like an "export artifact" and started feeling like a usable documentation corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pain of Updates
&lt;/h2&gt;

&lt;p&gt;Then came the real operational problem.&lt;/p&gt;

&lt;p&gt;A corporate Confluence space can have thousands of pages. Most runs happen because a small subset changed. Full recrawls are wasteful, slow, and expensive in API quota terms. If every update cycle requires reprocessing everything, people stop running updates, and your mirror becomes stale.&lt;/p&gt;

&lt;p&gt;Initially, &lt;a href="https://developer.atlassian.com/cloud/confluence/advanced-searching-using-cql/" rel="noopener noreferrer"&gt;CQL&lt;/a&gt; looked like the obvious solution. Query pages by modification window, fetch only changed content, done. In theory, elegant. In practice, not sufficient.&lt;/p&gt;

&lt;p&gt;Why it did not hold up in production:&lt;/p&gt;

&lt;p&gt;CQL can tell you what changed according to indexed metadata, but does not magically solve all dependency and consistency issues for a local mirror. Link graphs can shift. Referenced pages may become relevant without being directly changed in a way your query catches at the right time. Some operational edge cases appear around indexing lag and query behavior that are tolerable in UI search but painful for deterministic synchronization.&lt;/p&gt;

&lt;p&gt;I needed a model that prioritized reliable convergence over clever query shortcuts.&lt;/p&gt;

&lt;p&gt;The solution became an incremental strategy backed by checkpoints and deterministic traversal behavior. In short: keep the crawl model stable, detect what is dirty versus reusable, and make update decisions based on explicit processing state rather than optimistic assumptions.&lt;/p&gt;

&lt;p&gt;This is where the dual-checkpoint idea paid off:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A completed checkpoint tracks what finished processing.&lt;/li&gt;
&lt;li&gt;A successful checkpoint tracks what finished with zero errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That split avoids a common failure mode where partial runs accidentally look "healthy" and poison future incrementals. You can advance progress while still preserving correctness signals.&lt;/p&gt;

&lt;p&gt;The result is that updates mode can reuse clean artifacts aggressively while still rerendering genuinely dirty pages. It is fast enough to run frequently, and predictable enough to trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Choices
&lt;/h2&gt;

&lt;p&gt;Go was a deliberate choice, not just personal preference.&lt;/p&gt;

&lt;p&gt;For CLI tools, Go gives you a very practical package: fast startup, straightforward concurrency, solid standard library, and simple deployment through static binaries. That matters when your users might run the tool in local shells, CI jobs, or mixed developer environments across operating systems.&lt;/p&gt;

&lt;p&gt;The crawling workload itself also maps well to Go's model. You can manage concurrency and rate limiting cleanly without pulling in heavyweight runtime dependencies. The codebase stays compact and maintainable, which matters for a tool that has to evolve with API quirks.&lt;/p&gt;

&lt;p&gt;On distribution, Go keeps friction low. Cross-platform release artifacts for Linux, macOS, and Windows are easy to automate, and users can download, extract, and run without installing language runtimes. For internal tooling adoption, that is a huge win.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Chaos to a Working Mirror
&lt;/h2&gt;

&lt;p&gt;At this point, the major blockers were resolved: conversion fidelity, link rewriting, comments and attachments, and incremental update correctness.&lt;/p&gt;

&lt;p&gt;What emerged is not a one-off exporter but a repeatable mirror process. You can point it at seeds, run a full sync, then run updates regularly and keep a local Markdown representation of your Confluence knowledge that stays useful over time.&lt;/p&gt;

&lt;p&gt;That sounds simple now, but it took several iterations to make "works once" become "works repeatedly under real constraints".&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is It Good For
&lt;/h2&gt;

&lt;p&gt;The first obvious use case is a personal or team second brain. Once content is local Markdown, people can search and browse with tools they already trust instead of relying entirely on Confluence UI behavior.&lt;/p&gt;

&lt;p&gt;The second is offline and operational resilience. If network access is limited, if Confluence is degraded, or if you simply want a local snapshot for incident work, the mirror is immediately useful.&lt;/p&gt;

&lt;p&gt;The third is versioned knowledge management. Putting mirrored docs in Git gives you history, diffs, and visibility into how operational knowledge evolves. That is valuable for onboarding, audits, and postmortems.&lt;/p&gt;

&lt;p&gt;The fourth is machine workflows. Clean local Markdown plus metadata is a far better substrate for indexing and retrieval than trying to resolve everything live against Confluence APIs at query time.&lt;/p&gt;

&lt;p&gt;In practice, this means one mirror can serve multiple audiences: humans browsing docs, engineers diffing changes, and AI systems consuming structured text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next Steps
&lt;/h2&gt;

&lt;p&gt;The obvious next step is to feed the mirror into a &lt;a href="https://en.wikipedia.org/wiki/Retrieval-augmented_generation" rel="noopener noreferrer"&gt;RAG pipeline&lt;/a&gt;, but that should not mean "one page equals one chunk". That naive approach throws away structural signals and often hurts retrieval quality.&lt;/p&gt;

&lt;p&gt;A stronger pipeline should chunk by semantic boundaries: headings, sections, and content blocks that correspond to coherent ideas. It should preserve metadata such as page ID, title, source URL, section path, and update timestamp. It should also account for duplicated content, stale snapshots, and link context that may improve answer grounding.&lt;/p&gt;

&lt;p&gt;Another important step is retrieval strategy. Hybrid retrieval often works better than pure vector search for operational docs, because exact keywords (service names, env vars, incident IDs) matter. A good pipeline can combine lexical and semantic retrieval, then rerank with contextual scoring.&lt;/p&gt;

&lt;p&gt;There is also room for change-aware indexing: when the mirror updates, only re-embed affected chunks and keep stable identifiers so downstream stores do not churn unnecessarily.&lt;/p&gt;

&lt;p&gt;In other words, mirroring Confluence to Markdown is not the final destination. It is the foundation. Once that foundation is reliable, higher-level knowledge workflows become much easier to build correctly.&lt;/p&gt;

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

&lt;p&gt;This project began as frustration with documentation discoverability and ended as a practical data pipeline for both humans and machines.&lt;/p&gt;

&lt;p&gt;The core idea is simple: convert an operationally messy knowledge source into a local, readable, versionable, machine-friendly representation. The implementation was not simple at all, especially around conversion fidelity and incremental correctness, but the payoff is real: a Confluence space you can actually work with.&lt;/p&gt;

&lt;p&gt;If this sounds useful for your team, the tool is open source and ready to run:&lt;br&gt;
&lt;a href="https://github.com/gkoos/confluence2md" rel="noopener noreferrer"&gt;github.com/gkoos/confluence2md&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>rag</category>
    </item>
    <item>
      <title>Your Recursion Is Lying to You</title>
      <dc:creator>Gabor Koos</dc:creator>
      <pubDate>Sat, 09 May 2026 17:04:40 +0000</pubDate>
      <link>https://dev.to/gkoos/your-recursion-is-lying-to-you-2g7n</link>
      <guid>https://dev.to/gkoos/your-recursion-is-lying-to-you-2g7n</guid>
      <description>&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Glossary/Recursion" rel="noopener noreferrer"&gt;Recursion&lt;/a&gt; is one of those ideas developers learn early and trust for years. If the recursive step is simple and the base case is correct, the code feels clean and safe.&lt;/p&gt;

&lt;p&gt;It is elegant for a reason: many problems are naturally recursive, and the code often mirrors how we explain the logic out loud. For tree walks, nested structures, and divide-and-conquer patterns, recursion can be easier to read than explicit loops.&lt;/p&gt;

&lt;p&gt;The catch is physical limits. Even with a correct base case and sound logic, each recursive call still consumes stack space. At some depth, you crash with stack overflow.&lt;/p&gt;

&lt;p&gt;If you read &lt;a href="https://blog.gaborkoos.com/posts/2026-03-28-Your-Debounce-Is-Lying-to-You/" rel="noopener noreferrer"&gt;Your Debounce Is Lying to You&lt;/a&gt; and &lt;a href="https://blog.gaborkoos.com/posts/2026-03-31-Your-Throttling-Is-Lying-to-You/" rel="noopener noreferrer"&gt;Your Throttling Is Lying to You&lt;/a&gt;, this is the recursion version of the same pattern: elegant abstraction, hidden operational edge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem Setup: Recursion Hits The Wall
&lt;/h2&gt;

&lt;p&gt;You can run everything below directly in a browser console. Let's start simple: a recursive sum of all integers from 1 to n.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 55&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now push a big input:&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="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// RangeError or InternalError: too much recursion in most JS runtimes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What just happened? The function is logically correct, but each call to &lt;code&gt;sum&lt;/code&gt; stays on the stack until the one below it returns. At depth 100,000 the runtime runs out of stack space and throws. It has nothing to do with the result being wrong, it is purely a physical limit on how many nested frames the runtime can hold at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tail Recursion Rescue Story
&lt;/h2&gt;

&lt;p&gt;The usual next step is &lt;a href="https://en.wikipedia.org/wiki/Tail_call" rel="noopener noreferrer"&gt;tail call optimization&lt;/a&gt;. The idea is simple: make the recursive call the last thing the function does, so the runtime can reuse the same frame instead of pushing a new one.&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;sum&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; tail-recursive, even though the recursive call appears on the last line. After &lt;code&gt;sum(n - 1)&lt;/code&gt; returns, there is still pending work: the result must be added to &lt;code&gt;n&lt;/code&gt;. &lt;strong&gt;A call is only in tail position when its return value is forwarded immediately, with no pending computation afterward&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The tail-recursive version moves that pending state into an accumulator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sumTR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sumTR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;sumTR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 55&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here &lt;code&gt;sumTR(...)&lt;/code&gt; is the very last thing that happens — no pending &lt;code&gt;+&lt;/code&gt;, no pending anything. The running total lives in &lt;code&gt;acc&lt;/code&gt;, not in waiting stack frames. In theory, a runtime that implements TCO can execute this in constant stack space regardless of depth.&lt;/p&gt;

&lt;p&gt;Now repeat the same stress input:&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="nf"&gt;sumTR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// may still throw RangeError!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even with correct tail-recursive structure, many JavaScript runtimes still allocate a new stack frame per call and throw at large depth. This surprises developers who expect TCO to be a universal guarantee. &lt;a href="https://262.ecma-international.org/6.0/#sec-tail-position-calls" rel="noopener noreferrer"&gt;ECMAScript 2015&lt;/a&gt; formally specified proper tail calls in strict mode, but most engines never adopted the feature consistently. Some shipped it and then walked it back due to performance regressions. Others never implemented it at all. The result is that you cannot assume tail recursion is stack-safe in production JavaScript, even if the code is correctly structured for TCO.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Note on Fibonacci
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Fibonacci_number" rel="noopener noreferrer"&gt;Fibonacci&lt;/a&gt; is the go-to recursion textbook example and it does run into stack limits too, but it carries a second problem that makes it even worse: &lt;strong&gt;exponential time complexity&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fib&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fib&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;fib&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each call branches into two more calls, so the total number of calls grows as O(2ⁿ). &lt;code&gt;fib(30)&lt;/code&gt; already makes over a million calls; &lt;code&gt;fib(50)&lt;/code&gt; is in the tens of billions. In a browser this freezes the tab long before any stack limit is reached, which makes the failure mode look identical to a stack overflow but have a completely different root cause.&lt;/p&gt;

&lt;p&gt;The tail-recursive version of Fibonacci:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fibTR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fibTR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This version runs in linear time, but it still risks stack overflow at large &lt;code&gt;n&lt;/code&gt; due to the same TCO uncertainty. The exponential version is a red herring for this discussion because it fails for a completely different reason: stack overflow and exponential blowup are two separate problems. They look the same from the outside (the page hangs or crashes) but require completely different fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtime Reality (At The Time Of Writing)
&lt;/h2&gt;

&lt;p&gt;At the time of writing (May 2026), proper tail-call optimization support is not something you can count on across JavaScript runtimes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Proper Tail Calls You Can Rely On?&lt;/th&gt;
&lt;th&gt;Practical Take&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome&lt;/td&gt;
&lt;td&gt;V8&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Do not expect stack-safe tail recursion.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;V8&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Tail-recursive code can still overflow.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deno&lt;/td&gt;
&lt;td&gt;V8&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Same operational expectation as Node/Chrome.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firefox&lt;/td&gt;
&lt;td&gt;SpiderMonkey&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Do not treat tail recursion as a safety guarantee.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safari&lt;/td&gt;
&lt;td&gt;JavaScriptCore&lt;/td&gt;
&lt;td&gt;Inconsistent — JSC has shipped and walked back TCO across versions&lt;/td&gt;
&lt;td&gt;Do not rely on it; behavior has varied enough across releases that it is not a stable guarantee.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bun&lt;/td&gt;
&lt;td&gt;JavaScriptCore-based&lt;/td&gt;
&lt;td&gt;Engine-dependent, not a cross-runtime guarantee&lt;/td&gt;
&lt;td&gt;Verify on exact version; do not assume universal behavior.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key point is portability. Tail recursion is a property of function structure, while stack reuse is a property of runtime implementation. Even if one engine behaves better in one version, production JavaScript usually spans multiple targets, and correctness should not depend on optimizer-specific behavior. A function can be perfectly tail-recursive in shape and still consume stack per call in the environments your users actually run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Better Patterns for Production Code
&lt;/h2&gt;

&lt;p&gt;Every recursive function can be rewritten iteratively, and that is usually the safest choice in production when input depth can grow. Iteration does not rely on runtime optimizations for stack safety, because it does not consume stack frames per step. This does not mean giving up the recursive mental model. You can still write code that is conceptually recursive but uses an explicit stack or a &lt;em&gt;trampoline&lt;/em&gt; to manage control flow without hitting physical limits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sumIter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;sumIter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// no recursive stack growth&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Trampoline Pattern
&lt;/h2&gt;

&lt;p&gt;If you want to keep the recursive structure for readability but need to avoid stack growth, you can use a trampoline: a loop that repeatedly calls a function that returns either a final result or another function to call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;trampoline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;while &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;result&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sumTrampoline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;sumTrampoline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;trampoline&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;sumTrampoline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// no stack overflow, still tail-recursive in spirit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trampolines trade stack safety for additional function allocations and dispatch overhead, so they are most useful when preserving recursive structure matters more than raw performance.&lt;/p&gt;

&lt;p&gt;This approach scales in a way that does not depend on runtime tail-call behavior, which is exactly what you want when input depth can grow. If recursive structure improves readability for a particular problem, these techniques let you keep that mental model with explicit tradeoffs instead of implicit runtime assumptions.&lt;/p&gt;

&lt;p&gt;A useful rule of thumb is to keep recursion for small, bounded depths that you control, and &lt;strong&gt;switch to iterative control flow as soon as depth is user-driven, data-driven, or operationally uncertain&lt;/strong&gt;. For hot paths, benchmark both styles, but do not base correctness on assumed TCO.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Never assume TCO in JavaScript for production-critical paths.&lt;/li&gt;
&lt;li&gt;Test with realistic upper bounds, not toy input sizes.&lt;/li&gt;
&lt;li&gt;Favor iterative implementations when depth can grow.&lt;/li&gt;
&lt;li&gt;Treat recursion as a readability tool, not a stack-safety guarantee.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Recursion itself is not the enemy, unverified runtime assumptions are. Tail-recursive shape does not automatically make JavaScript stack-safe, and that gap is where many "works on my machine" surprises come from in production.&lt;/p&gt;

&lt;p&gt;Use recursion where it improves clarity and depth is genuinely bounded. When depth can grow or input is outside your control, prefer iterative designs that make stack behavior explicit and portable.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Decorating Promises Without Breaking Them</title>
      <dc:creator>Gabor Koos</dc:creator>
      <pubDate>Fri, 10 Apr 2026 14:18:20 +0000</pubDate>
      <link>https://dev.to/gkoos/decorating-promises-without-breaking-them-2jf4</link>
      <guid>https://dev.to/gkoos/decorating-promises-without-breaking-them-2jf4</guid>
      <description>&lt;p&gt;I wanted &lt;code&gt;.get().json()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This came up while building convenience plugins for &lt;a href="https://github.com/fetch-kit/ffetch" rel="noopener noreferrer"&gt;ffetch&lt;/a&gt;, a lightweight fetch wrapper focused on keeping native semantics intact. Libraries like &lt;a href="https://github.com/sindresorhus/ky" rel="noopener noreferrer"&gt;ky&lt;/a&gt; solve the ergonomics problem by introducing a custom &lt;code&gt;Response&lt;/code&gt;-like object, which works great until something outside the library expects a plain &lt;code&gt;Response&lt;/code&gt;. I wanted a different path.&lt;/p&gt;

&lt;p&gt;Not because I needed it, strictly speaking. &lt;code&gt;await fetch('/api/todos/1')&lt;/code&gt; followed by &lt;code&gt;await response.json()&lt;/code&gt; works perfectly fine. But after the hundredth time writing that two-step dance across a codebase, you start reaching for something cleaner.&lt;/p&gt;

&lt;p&gt;The usual answer is a wrapper class or a custom Promise subclass. Both work, but both carry a hidden cost: you are now responsible for whatever happens when you swap out the native &lt;code&gt;Response&lt;/code&gt; for your own abstraction. &lt;code&gt;instanceof&lt;/code&gt; checks break. Framework integrations that inspect the response directly can behave unexpectedly. And the moment someone passes your custom object into something that expected a plain &lt;code&gt;Response&lt;/code&gt;, you have a problem.&lt;/p&gt;

&lt;p&gt;I wanted a different answer. This is about that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Goal
&lt;/h2&gt;

&lt;p&gt;The cleaner call site I was after looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;todo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/todos/1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two requirements in tension. On one hand, &lt;code&gt;.json()&lt;/code&gt; should be reachable without a separate &lt;code&gt;await&lt;/code&gt; and variable assignment. On the other hand, &lt;code&gt;await client.get('/api/todos/1')&lt;/code&gt; should still resolve to a genuine, unmodified &lt;code&gt;Response&lt;/code&gt; — not a wrapper, not a subclass, not a Proxy.&lt;/p&gt;

&lt;p&gt;Most approaches collapse this tension by picking one side. Either you get ergonomics and lose native semantics, or you keep native semantics and write the two-liner. The question is whether you can actually have both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mechanism
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;Promise&lt;/code&gt; in JavaScript is an object. Like any object, you can assign properties to it at runtime.&lt;/p&gt;

&lt;p&gt;That is the whole trick. Instead of wrapping the Promise or replacing it with something else, you decorate it in place: attach the convenience methods directly as properties on the Promise instance returned by the fetch call.&lt;/p&gt;

&lt;p&gt;Here is what that looks like in practice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;attachResponseShortcuts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;descriptor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;this&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;enumerable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;writable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;configurable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineProperties&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="nf"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="nf"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="nf"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nf"&gt;descriptor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;promise&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things are happening here that are worth unpacking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Property descriptors, not assignment.&lt;/strong&gt; Using &lt;code&gt;Object.defineProperties&lt;/code&gt; instead of &lt;code&gt;promise.json = fn&lt;/code&gt; gives explicit control over the property attributes. Each method is &lt;code&gt;enumerable: false&lt;/code&gt; which means it stays invisible to &lt;code&gt;for...in&lt;/code&gt; loops, &lt;code&gt;Object.keys&lt;/code&gt;, and JSON serialization. (They will still show up in browser DevTools when you expand the object and in &lt;code&gt;Object.getOwnPropertyDescriptors()&lt;/code&gt;, but that is useful for debugging anyway — the point is they do not pollute standard iteration or JSON output.) It is &lt;code&gt;writable: false&lt;/code&gt; and &lt;code&gt;configurable: false&lt;/code&gt;, so it cannot be accidentally overwritten or deleted at runtime. This is intentional: without these locks, a careless reassignment (&lt;code&gt;promise.json = myMock&lt;/code&gt;) would silently break the convenience layer for everyone holding that promise. The tradeoff is that it also prevents &lt;em&gt;intentional&lt;/em&gt; overrides — if you need to mock &lt;code&gt;.json()&lt;/code&gt; in a test, you cannot. This is a deliberate choice favoring safety over flexibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forwarding, not reimplementing.&lt;/strong&gt; Each method is a one-liner that calls &lt;code&gt;.then()&lt;/code&gt; on the Promise itself (via &lt;code&gt;this&lt;/code&gt;) and delegates immediately to the native &lt;code&gt;Response&lt;/code&gt; method. The parsing behavior, error handling, and body consumption rules all come from the browser or runtime. We are not reimplementing anything. The methods are thin pass-throughs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Promise remains a Promise.&lt;/strong&gt; &lt;code&gt;await client.get('/api/todos/1')&lt;/code&gt; still resolves to the same native &lt;code&gt;Response&lt;/code&gt; it always did. The added methods live on the instance itself, not on the prototype chain like native methods do — they are invisible properties on the Promise object. They do not affect the resolution value, the prototype chain, or any standard Promise behavior. (This is a meaningful difference: calls to &lt;code&gt;promise.constructor&lt;/code&gt; or &lt;code&gt;Object.getPrototypeOf(promise)&lt;/code&gt; see an untouched Promise, not a subclass or wrapper.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency
&lt;/h2&gt;

&lt;p&gt;If multiple plugins or hooks might touch the same promise — which is the case in a plugin-based architecture — you need to guard against decorating the same object twice. &lt;code&gt;Object.defineProperties&lt;/code&gt; will throw if you try to redefine a non-configurable property.&lt;/p&gt;

&lt;p&gt;A marker handles this, and this is one of the rare cases where a &lt;code&gt;Symbol&lt;/code&gt; is genuinely useful: it provides collision-free identity that no other code can accidentally claim.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DECORATED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Symbol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ffetch.responseShortcutsDecorated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;attachResponseShortcuts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="nx"&gt;DECORATED&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;promise&lt;/span&gt;

  &lt;span class="c1"&gt;// ... defineProperties ...&lt;/span&gt;

  &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DECORATED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;enumerable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;promise&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The marker is invisible to &lt;code&gt;Object.keys&lt;/code&gt;, &lt;code&gt;Object.getOwnPropertyNames&lt;/code&gt;, and iteration — only findable via &lt;code&gt;Object.getOwnPropertySymbols&lt;/code&gt; if you explicitly look for it. Decoration becomes a safe, idempotent operation regardless of call order, with zero risk of collision with any third-party code or browser internals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Typing It
&lt;/h2&gt;

&lt;p&gt;TypeScript does not know about properties you attach at runtime, so you have to tell it. The cleanest model here is an intersection type: the call site return type is &lt;code&gt;Promise&amp;lt;Response&amp;gt;&lt;/code&gt; intersected with the shortcut interface.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ResponseShortcuts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Blob&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;ArrayBuffer&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nf"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FormData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;DecoratedPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ResponseShortcuts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is honest. &lt;code&gt;DecoratedPromise&lt;/code&gt; really is both things simultaneously: a standard Promise that resolves to &lt;code&gt;Response&lt;/code&gt;, and an object that happens to have five extra methods. The intersection expresses both without hiding either.&lt;/p&gt;

&lt;p&gt;When the library does not have the plugin installed, the return type is &lt;code&gt;Promise&amp;lt;Response&amp;gt;&lt;/code&gt; with no extras. When it does, it is &lt;code&gt;Promise&amp;lt;Response&amp;gt; &amp;amp; ResponseShortcuts&lt;/code&gt;. TypeScript catches you if you try to call &lt;code&gt;.json()&lt;/code&gt; without the plugin, and it autocompletes when you have it. No runtime cost either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tradeoffs Worth Naming
&lt;/h2&gt;

&lt;p&gt;This technique is additive, not transformative. That is its strength and its limit.&lt;/p&gt;

&lt;p&gt;It cannot change what &lt;code&gt;Response&lt;/code&gt; contains. If you call &lt;code&gt;.json()&lt;/code&gt; on a response that came back with a &lt;code&gt;text/html&lt;/code&gt; body, you get the same parse error you would have got with the two-liner. The shortcut is a convenience, not a type-safe schema layer.&lt;/p&gt;

&lt;p&gt;Body consumption rules are also unchanged. &lt;code&gt;Response&lt;/code&gt; bodies can only be read once — calling &lt;code&gt;.json()&lt;/code&gt; and then separately awaiting the response and calling &lt;code&gt;.json()&lt;/code&gt; again will fail, exactly as native fetch would. Decoration does not change the underlying object.&lt;/p&gt;

&lt;p&gt;The TypeScript types also do not capture body consumption state. If the response body was already read (e.g., by an upstream handler or middleware), calling &lt;code&gt;.json()&lt;/code&gt; will throw at runtime. TypeScript will not catch this — the types express the structural shape of the methods, not the preconditions for their success. This is a general limitation of modeling &lt;code&gt;Response&lt;/code&gt; state in TypeScript, not specific to this technique, but it is worth knowing: the intersection type &lt;code&gt;Promise&amp;lt;Response&amp;gt; &amp;amp; ResponseShortcuts&lt;/code&gt; is a shape guarantee, not a behavioral one.&lt;/p&gt;

&lt;p&gt;And if you or your team prefer strict explicitness — no augmented promise objects, all parsing explicit — then this pattern is probably not the right call. It is a style choice. The native two-liner is perfectly readable, just longer.&lt;/p&gt;

&lt;p&gt;Where this genuinely shines is in a plugin or middleware architecture where you want to offer ergonomics as opt-in behavior. The baseline remains untouched, native fetch-compatible, and requires zero knowledge of the convenience layer to work with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Broader Point
&lt;/h2&gt;

&lt;p&gt;What I find interesting about this technique is that it demonstrates a property of JavaScript that is easy to forget: objects are open. A Promise is not a sealed system. You can extend it in flight without wrapping or subclassing, and without disturbing the contract anyone else has with it.&lt;/p&gt;

&lt;p&gt;Preserve native behavior first. Layer ergonomics second, explicitly, and as close to invisibly as possible.&lt;/p&gt;

&lt;p&gt;If the shortcut is there and you use it, you gain a line. If the shortcut is there and you do not use it, nothing changes. That is the shape of a good opt-in.&lt;/p&gt;

&lt;p&gt;The full implementation lives in &lt;a href="https://github.com/fetch-kit/ffetch" rel="noopener noreferrer"&gt;ffetch&lt;/a&gt; if you want to see it in context. But the technique itself applies anywhere you need to decorate promises with convenience methods.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Introducing the Fetch Client Chaos Arena</title>
      <dc:creator>Gabor Koos</dc:creator>
      <pubDate>Sun, 05 Apr 2026 13:40:02 +0000</pubDate>
      <link>https://dev.to/gkoos/introducing-the-fetch-client-chaos-arena-49k3</link>
      <guid>https://dev.to/gkoos/introducing-the-fetch-client-chaos-arena-49k3</guid>
      <description>&lt;p&gt;There are many HTTP clients in the JavaScript ecosystem, and while they all solve similar problems, they can behave very differently under stress, retries, and failures. Picking the right one is not always straightforward.&lt;/p&gt;

&lt;p&gt;Introducing &lt;a href="https://fetch-kit.github.io/ffetch-demo/" rel="noopener noreferrer"&gt;ffetch-demo&lt;/a&gt;: a live browser arena for benchmarking JavaScript HTTP clients under controlled network chaos. The idea is simple: run the same request workload through different clients and compare how they behave when conditions get rough.&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%2Fknzu34jdd290c59upj2m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fknzu34jdd290c59upj2m.png" alt="Chaos Arena screenshot" width="800" height="438"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the demo, you can configure chaos scenarios such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;latency injection&lt;/li&gt;
&lt;li&gt;random failures and drops&lt;/li&gt;
&lt;li&gt;status-code spikes&lt;/li&gt;
&lt;li&gt;retry pressure and timeout stress&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Built With chaos-fetch
&lt;/h2&gt;

&lt;p&gt;The chaos layer in the arena is powered by &lt;code&gt;@fetchkit/chaos-fetch&lt;/code&gt;, which makes it easy to apply deterministic and randomized network stressors through middleware-style configuration.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/@fetchkit/chaos-fetch" rel="noopener noreferrer"&gt;@fetchkit/chaos-fetch&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/fetch-kit/chaos-fetch" rel="noopener noreferrer"&gt;fetch-kit/chaos-fetch&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Current clients in the arena:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;native &lt;code&gt;fetch&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;axios&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ky&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@fetchkit/ffetch&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The output focuses on practical reliability signals (success/failure rates, error patterns, and latency distributions) so you can quickly see behavioral differences between clients.&lt;/p&gt;

&lt;p&gt;Live demo: &lt;a href="https://fetch-kit.github.io/ffetch-demo/" rel="noopener noreferrer"&gt;fetch-kit.github.io/ffetch-demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/fetch-kit/ffetch-demo" rel="noopener noreferrer"&gt;fetch-kit/ffetch-demo&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Further Reading
&lt;/h2&gt;

&lt;p&gt;If you want to go deeper into the testing philosophy and tooling around this demo, these earlier posts provide context:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://blog.gaborkoos.com/posts/2025-10-07-Chaos-Driven-Testing-for-Full-Stack-Apps/" rel="noopener noreferrer"&gt;Chaos-Driven Testing for Full Stack Apps: Integration Tests That Break (and Heal)&lt;/a&gt; explains how to validate behavior under intentional failure in end-to-end flows.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://blog.gaborkoos.com/posts/2025-10-01-Small-Scale-Chaos-Testing-The-Missing-Step-Before-Production/" rel="noopener noreferrer"&gt;Small-Scale Chaos Testing: The Missing Step Before Production&lt;/a&gt; makes the case for practical chaos experiments in dev and staging.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://blog.gaborkoos.com/posts/2025-09-27-Introducing-chaos-fetch-network-chaos-injection-for-fetch-requests/" rel="noopener noreferrer"&gt;Introducing chaos-fetch: Network Chaos Injection for Fetch Requests&lt;/a&gt; introduces the library and core capabilities behind the arena's chaos model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have ideas for additional scenarios or clients, feedback and contributions are very welcome.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>showdev</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Your Debounce Is Lying to You</title>
      <dc:creator>Gabor Koos</dc:creator>
      <pubDate>Sat, 28 Mar 2026 06:52:27 +0000</pubDate>
      <link>https://dev.to/gkoos/your-debounce-is-lying-to-you-3e3h</link>
      <guid>https://dev.to/gkoos/your-debounce-is-lying-to-you-3e3h</guid>
      <description>&lt;p&gt;&lt;a href="https://www.geeksforgeeks.org/javascript/debouncing-in-javascript/" rel="noopener noreferrer"&gt;Debounce&lt;/a&gt; is one of those patterns every frontend developer learns early and keeps using forever.&lt;/p&gt;

&lt;p&gt;At its core, debouncing does one thing well: it coalesces a burst of calls into one invocation after a quiet window. That is a great fit for noisy UI signals.&lt;/p&gt;

&lt;p&gt;Its most familiar use case is autocomplete, but the same pattern applies to resize handlers, scroll listeners, live validation, filter controls, and telemetry hooks.&lt;/p&gt;

&lt;p&gt;A typical implementation looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/search?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks disciplined. It feels efficient. It ships fast.&lt;/p&gt;

&lt;p&gt;And this is where the title comes from.&lt;/p&gt;

&lt;p&gt;The issue is not debounce itself. The issue is this vanilla debounce + &lt;code&gt;fetch&lt;/code&gt; pattern once real network behavior enters the picture.&lt;/p&gt;

&lt;p&gt;It gives the feeling that requests are "under control," but it does not control request lifecycle: response ordering, cancellation of stale work, or failure behavior.&lt;/p&gt;

&lt;p&gt;That is why it feels like debounce is "lying" in production: the UI looks smoothed, while the network layer is still fragile.&lt;/p&gt;

&lt;p&gt;In this article, we will keep debounce for what it is good at (UI smoothing), then harden the request path with cancellation, retries, and better error handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Illusion of "Fixed" Behavior
&lt;/h2&gt;

&lt;p&gt;Debounce is convincing: you type quickly, the UI triggers fewer calls, and the network tab looks quieter. It &lt;em&gt;feels&lt;/em&gt; like the system is now stable. But in production, under real network conditions, many things can go wrong. You will experience stale data, wasted requests, silent failures and other unexpected behaviors.&lt;/p&gt;

&lt;p&gt;This is true for any network request, but debounce adds another layer of complexity: it makes the UI look smooth while the network can still be unpredictable. This mismatch can create a false sense of security.&lt;/p&gt;

&lt;p&gt;Debounce itself only guarantees one thing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I won't call this function too often."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Debounce smooths input frequency, not request lifecycle. It does &lt;strong&gt;not&lt;/strong&gt; guarantee:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Responses arrive in order.&lt;/li&gt;
&lt;li&gt;Stale requests stop running.&lt;/li&gt;
&lt;li&gt;Failures are handled consistently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words: it is &lt;strong&gt;a UI pattern, not a network pattern&lt;/strong&gt;. So you have to make sure the underlying network layer is robust enough to handle real-world conditions. Before we examine what can go wrong, let's set the stage!&lt;/p&gt;

&lt;h2&gt;
  
  
  The Companion Code
&lt;/h2&gt;

&lt;p&gt;To make these problems visible, we have a companion demo app with a single text input where every keystroke triggers a debounced request to &lt;code&gt;/api/echo?q=&amp;lt;input&amp;gt;&lt;/code&gt;. The backend is an Express server that returns &lt;code&gt;{ query, timestamp }&lt;/code&gt;, and the frontend appends each response to a div as &lt;code&gt;query@timestamp&lt;/code&gt;. The stack is minimal: Node.js + Express on the backend, and plain HTML/CSS/JavaScript in the browser.&lt;/p&gt;

&lt;p&gt;Clone the repo and install dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/gkoos/article-debouncing.git
&lt;span class="nb"&gt;cd &lt;/span&gt;article-debouncing
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then start the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And navigate to &lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt; in your browser. You will see something like this:&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%2Fm4xngvcrm0yohu7f37ie.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm4xngvcrm0yohu7f37ie.png" alt="Screenshot" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now, as you type in the input field, you will see the responses coming back in the list below:&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%2Fllmo819ewu46w267kgxe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fllmo819ewu46w267kgxe.png" alt="Screenshot" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The UI also shows toast notifications for request success and failure, which will become relevant in later sections.&lt;/p&gt;

&lt;p&gt;This is our baseline setup, it demonstrates the basic pattern. There is a 300ms debounce on the input, and the backend immediately responds with the query and a timestamp. The UI appends each response to the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: Race Conditions (aka Stale UI)
&lt;/h2&gt;

&lt;p&gt;On your local machine, everything is fast and smooth. But in production, network conditions are unpredictable. Requests can take varying amounts of time to complete, so there is no guarantee that responses will arrive in the same order they were sent. Let's see what happens if we add random delays to the server response to simulate real network conditions.&lt;/p&gt;

&lt;p&gt;Check out the &lt;code&gt;01-stale-requests&lt;/code&gt; branch and restart the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git checkout 01-stale-requests
npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We added a middleware that introduces a random delay of 0–1000ms for each request. Now, when you type quickly, you might see responses arriving out of order:&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%2Ff1oypbrknka9os59x9g2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff1oypbrknka9os59x9g2.png" alt="Screenshot" width="779" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;We typed &lt;code&gt;12345678&lt;/code&gt;, but the UI shows &lt;code&gt;1234567&lt;/code&gt;! The response for &lt;code&gt;7&lt;/code&gt; came back &lt;em&gt;after&lt;/em&gt; &lt;code&gt;8&lt;/code&gt;, so the UI is now stale. This is a classic race condition, and debounce itself does not prevent it. The UI is showing results for an older query, which can lead to confusion and errors in a real application.&lt;/p&gt;

&lt;p&gt;How to fix this? We need to ensure that only the latest request's response is processed, and any previous requests are either cancelled or ignored. We could implement a simple version of this by keeping track of the latest query and ignoring responses that don't match it. But that would still allow all requests to run, which is inefficient. A better approach is to use the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortController" rel="noopener noreferrer"&gt;&lt;code&gt;AbortController&lt;/code&gt;&lt;/a&gt; API to cancel stale requests, so they don't consume resources or trigger side effects when they complete.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AbortController&lt;/code&gt; is a browser-native API. You create a controller, pass its &lt;code&gt;signal&lt;/code&gt; to &lt;code&gt;fetch&lt;/code&gt;, and call &lt;code&gt;abort()&lt;/code&gt; whenever you want to cancel the request. The fetch will throw an &lt;code&gt;AbortError&lt;/code&gt;, which you can catch and ignore since it's expected.&lt;/p&gt;

&lt;p&gt;Here is the updated debounce callback with cancellation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;debouncedFetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;debounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/echo?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// render data...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// handle real errors...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things changed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Before each request, we abort any in-flight request from the previous call and create a fresh controller.&lt;/li&gt;
&lt;li&gt;After catching an error, we check if it's an &lt;code&gt;AbortError&lt;/code&gt; and return early: these are expected and not real failures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result: in normal flow, only the last request in a typing burst ever completes. Previous ones are cancelled at the network level, not just ignored after the fact. (For absolute safety, you can also guard the render step with a request ID check. This covers the tiny edge window where a response resolves right before a newer request aborts the previous one.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Network Failures
&lt;/h2&gt;

&lt;p&gt;The network is not only unpredictable in terms of latency, but also in terms of reliability. Sometimes a request can fail that would have succeeded if retried. This can be due to transient server issues like network congestion, temporary spikes in load, or database timeouts. If we want a more robust user experience, we need to handle these failures gracefully.&lt;/p&gt;

&lt;p&gt;Let's simulate random failures in our backend. Check out the &lt;code&gt;02-failures&lt;/code&gt; branch and restart the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git checkout 02-failures
npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This version of the server adds a random failure mechanism: each request has a 40% chance to fail with a 500 error. Now this may be too aggressive, but it will help us see the problem clearly. When you type in the input field, you will see some requests fail:&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%2F58wp08o4uljyaevogd7m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F58wp08o4uljyaevogd7m.png" alt="Screenshot" width="775" height="442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Well, that's not good. First, our UI shows &lt;code&gt;undefined@undefined&lt;/code&gt; when a request fails. That happens because the server returns &lt;code&gt;{ error: 'Internal Server Error' }&lt;/code&gt; on a 500, so &lt;code&gt;data.query&lt;/code&gt; and &lt;code&gt;data.timestamp&lt;/code&gt; are both &lt;code&gt;undefined&lt;/code&gt;. Vanilla &lt;code&gt;fetch&lt;/code&gt; doesn't throw on HTTP error status codes: it only rejects on network failures. So we need to check &lt;code&gt;response.ok&lt;/code&gt; ourselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/echo?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now a 500 throws before we ever touch the body, the &lt;code&gt;catch&lt;/code&gt; handles it, and the error toast shows instead of broken output.&lt;/p&gt;

&lt;p&gt;But that's just the start. In a real app, you would want to implement some retry logic for transient failures. For example, you could automatically retry a failed request up to 3 times with exponential backoff. This way, if a request fails due to a temporary issue, it has a chance to succeed without the user having to do anything.&lt;/p&gt;

&lt;p&gt;To implement this manually you'd need to write retry loops, track attempt counts, implement backoff timing, and make sure none of it fires after a cancellation. That's not trivial, and it's not the interesting part of your app, so let's use a library for that.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@fetchkit/ffetch&lt;/code&gt; is a thin wrapper around &lt;code&gt;fetch&lt;/code&gt; that handles exactly this. We'll use it for the retry behavior in our demo.&lt;/p&gt;

&lt;p&gt;There are good alternatives in this space (for example &lt;code&gt;ky&lt;/code&gt;, &lt;code&gt;axios&lt;/code&gt;, or a custom wrapper). I chose &lt;code&gt;ffetch&lt;/code&gt; here because it keeps a &lt;code&gt;fetch&lt;/code&gt;-compatible API surface and handles abort-aware retries cleanly.&lt;/p&gt;

&lt;p&gt;Since this is a minimal demo with no build step, we load it directly from a CDN rather than installing it. In a real project you'd &lt;code&gt;npm install @fetchkit/ffetch&lt;/code&gt; and import it normally, but here a single import line is enough:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://esm.sh/@fetchkit/ffetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// retry up to 3 times on failure&lt;/span&gt;
  &lt;span class="na"&gt;shouldRetry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// only retry on 5xx errors&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;api&lt;/code&gt; has the exact same call signature as &lt;code&gt;fetch&lt;/code&gt;: same arguments, same return type. You drop it in as a replacement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/echo?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this buys us in this specific scenario:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;retries: 3&lt;/code&gt;&lt;/strong&gt; — if the server returns a 500, ffetch retries up to 3 more times before giving up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;shouldRetry&lt;/code&gt;&lt;/strong&gt; — we only retry on 5xx; anything else (network error, abort) propagates immediately&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;abort-aware backoff&lt;/strong&gt; — if &lt;code&gt;controller.abort()&lt;/code&gt; fires during the delay between retries, the wait exits immediately and the abort propagates; no stale work keeps running in the background&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is quite convenient here. Without it, aborting a request mid-retry would cut the active fetch but leave the backoff timer running, which would then fire another fetch attempt that instantly aborts. Now we handle this correctly.&lt;/p&gt;

&lt;p&gt;There's one more thing we can clean up. Because the native &lt;code&gt;fetch&lt;/code&gt; does not throw on HTTP error status codes (one of the pain points of the API), we had to add a manual check:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ffetch&lt;/code&gt; can handle this too. With the &lt;code&gt;throwOnHttpError: true&lt;/code&gt; config option, any HTTP error response throws automatically, no manual check needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;shouldRetry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;throwOnHttpError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the fetch call is just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/echo?q=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;catch&lt;/code&gt; block still handles everything - HTTP errors, network failures, real errors - without any extra branching in the happy path.&lt;/p&gt;

&lt;p&gt;The final implementation can be found in the &lt;code&gt;03-fixed&lt;/code&gt; branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git checkout 03-fixed
npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For reference, the full code for the debounce callback with &lt;code&gt;ffetch&lt;/code&gt; can be found &lt;a href="https://github.com/gkoos/article-debouncing/blob/03-fixed/src/public/index.html" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Debounce is not the problem. The problem is treating it as a complete solution for network control when it only handles one dimension of it: call frequency. It is a very useful pattern for smoothing out noisy UI signals, but it does not handle the complexities of real network behavior. To build a robust application, you need to complement debounce with proper request lifecycle management: cancellation of stale requests, retries with backoff for transient failures, and consistent error handling. This way, you can ensure that your UI remains not only responsive but also accurate even under unpredictable network conditions.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>frontend</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Developing and Benchmarking the Same Feature in Node and Go</title>
      <dc:creator>Gabor Koos</dc:creator>
      <pubDate>Thu, 19 Mar 2026 19:43:56 +0000</pubDate>
      <link>https://dev.to/gkoos/developing-and-benchmarking-the-same-feature-in-node-and-go-4lfl</link>
      <guid>https://dev.to/gkoos/developing-and-benchmarking-the-same-feature-in-node-and-go-4lfl</guid>
      <description>&lt;p&gt;When I started building chaos-proxy, the initial goal was simple: make API chaos testing practical for JavaScript and TypeScript teams. I wanted something that could sit between an app and its upstream API and introduce realistic turbulence on demand: latency spikes, intermittent failures, and other behavior that makes integration tests feel closer to production.&lt;/p&gt;

&lt;p&gt;Node.js was the obvious first runtime for that because the ecosystem, tooling, and middleware ergonomics are excellent for rapid iteration. It is hard to overstate how productive that setup is when the main audience is already living in npm, TypeScript, and JavaScript test runners.&lt;/p&gt;

&lt;p&gt;Later, I rewrote the same proxy in Go to push raw proxy performance further and support higher throughput under load. The intent was not to replace one with the other philosophically, but to explore a different optimization frontier with the same product idea.&lt;/p&gt;

&lt;p&gt;This post documents what happened when I implemented the same non-trivial feature in both runtimes: hot config reload. Then I reran the benchmark from my previous article to see how the newer versions compare.&lt;/p&gt;

&lt;p&gt;The interesting part is not only the final numbers. It is also how two mature runtimes guide you toward different internal designs, even when you are enforcing the same external behavior contract.&lt;/p&gt;

&lt;p&gt;Old benchmark post:&lt;br&gt;
&lt;a href="https://blog.gaborkoos.com/posts/2025-10-11-Nodejs-vs-Go-in_Practice-Performance-Comparison-of-chaos-proxy-And-chaos-proxy-go/" rel="noopener noreferrer"&gt;https://blog.gaborkoos.com/posts/2025-10-11-Nodejs-vs-Go-in_Practice-Performance-Comparison-of-chaos-proxy-And-chaos-proxy-go/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Repositories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node implementation: &lt;a href="https://github.com/fetch-kit/chaos-proxy" rel="noopener noreferrer"&gt;https://github.com/fetch-kit/chaos-proxy&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Go implementation: &lt;a href="https://github.com/fetch-kit/chaos-proxy-go" rel="noopener noreferrer"&gt;https://github.com/fetch-kit/chaos-proxy-go&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Implementing Hot Config Reload in Two Runtimes
&lt;/h2&gt;

&lt;p&gt;The goal of hot config reload was to allow users to update the proxy's behavior without downtime. This means that when a new config is posted to the /reload endpoint, the proxy should parse, validate, and apply the new configuration atomically, without interrupting in-flight requests. This enables advanced testing scenarios where you can change the chaos behavior on the fly to model dynamic production conditions like feature rollouts, traffic shifts, or evolving failure modes.&lt;/p&gt;

&lt;p&gt;Both implementations follow the same external contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;POST /reload accepts a full config snapshot&lt;/li&gt;
&lt;li&gt;Parse -&amp;gt; validate -&amp;gt; build -&amp;gt; swap, all-or-nothing&lt;/li&gt;
&lt;li&gt;Deterministic in-flight behavior (request-start snapshot semantics)&lt;/li&gt;
&lt;li&gt;Reject concurrent reload requests&lt;/li&gt;
&lt;li&gt;Consistent status model (400, 409, 415, success returns version and reload duration)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the user-facing behavior is aligned. Clients see the same API and guarantees. The internal shape is where Node and Go felt very different.&lt;/p&gt;
&lt;h3&gt;
  
  
  Runtime model
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Node&lt;/strong&gt; leaned toward a dynamic runtime object: rebuild middleware/router chain, then swap the active runtime. That style maps naturally to the way Node applications are often composed. Rebuilds are straightforward to express, and the overall control flow stays compact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Go&lt;/strong&gt; leaned toward immutable runtime snapshots: config + router + version behind an atomic pointer. In practice, this makes the runtime feel more explicit. You can point to exactly what a request observed and exactly when a new version became active.&lt;/p&gt;
&lt;h3&gt;
  
  
  Concurrency model
&lt;/h3&gt;

&lt;p&gt;In &lt;strong&gt;Node&lt;/strong&gt;, most complexity is around making reload writes serialized and safe while requests continue flowing.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;Go&lt;/strong&gt;, the read/write split is explicit: request path loads one snapshot at request start, reload path builds fresh state under lock, then atomically swaps.&lt;/p&gt;

&lt;p&gt;Behaviorally both approaches are equivalent from a user perspective. The difference is mostly in how obvious the invariants are when you revisit the code weeks later.&lt;/p&gt;
&lt;h3&gt;
  
  
  In-flight guarantees
&lt;/h3&gt;

&lt;p&gt;Both versions guarantee request-start snapshot semantics.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;Node&lt;/strong&gt;, this is easier to accidentally violate if mutable shared state leaks into request handling.&lt;/p&gt;

&lt;p&gt;In &lt;strong&gt;Go&lt;/strong&gt;, the pointer-load-at-entry pattern makes this guarantee structurally harder to violate.&lt;/p&gt;

&lt;p&gt;That was one of the strongest practical contrasts for me: same requirement, different default safety profile.&lt;/p&gt;
&lt;h3&gt;
  
  
  Router lifecycle and rebuild mechanics
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Node&lt;/strong&gt; composition is lightweight and ergonomic for rebuilds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Go&lt;/strong&gt; rebuilds a fresh router and re-registers middleware/routes on each reload. Behavior is explicit and predictable at the snapshot level, with middleware execution order deterministic only when config uses ordered list elements (not multiple keys in one map). It can look verbose at first, but this explicitness pays off when debugging edge cases around reload timing.&lt;/p&gt;
&lt;h3&gt;
  
  
  Validation and rollback boundaries
&lt;/h3&gt;

&lt;p&gt;Both use the same pipeline: parse -&amp;gt; validate -&amp;gt; build -&amp;gt; swap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Node&lt;/strong&gt; gives more dynamic flexibility but needs stricter guard discipline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Go&lt;/strong&gt;'s type-driven pipeline made failure paths and rollback behavior cleaner to reason about.&lt;/p&gt;

&lt;p&gt;In both runtimes, treating build and swap as separate phases was the key to keeping rollback semantics simple.&lt;/p&gt;
&lt;h3&gt;
  
  
  Stateful middleware behavior
&lt;/h3&gt;

&lt;p&gt;Both implementations rebuild middleware instances on reload. That means in-memory middleware state (for example counters or local token buckets) resets by design after a successful reload. This is intentional and worth calling out to users because it is product behavior, not an implementation accident.&lt;/p&gt;
&lt;h2&gt;
  
  
  Benchmark Rerun
&lt;/h2&gt;

&lt;p&gt;After adding hot config reload support, I reran the old benchmark setup.&lt;/p&gt;

&lt;p&gt;The goal here was not to produce an absolute, universal number for every environment. The goal was to keep methodology stable enough to compare the old and new versions and see whether the relative shape changed.&lt;/p&gt;
&lt;h3&gt;
  
  
  System and Test Environment (Same Machine as the Old Article)
&lt;/h3&gt;

&lt;p&gt;This rerun was executed on the same machine as the benchmark in the previous article, with the same local topology (Caddy backend on localhost, proxy on localhost, load generated by hey on the same host).&lt;/p&gt;

&lt;p&gt;Machine characteristics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU: AMD Ryzen 7 5800H with Radeon Graphics&lt;/li&gt;
&lt;li&gt;Cores/Threads: 8 cores / 16 threads&lt;/li&gt;
&lt;li&gt;Base clock: 3.2 GHz&lt;/li&gt;
&lt;li&gt;RAM: 16 GB DDR4&lt;/li&gt;
&lt;li&gt;OS: Windows 10 Home 22H2 64-bit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Benchmark setup characteristics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Backend: Caddy serving /api/hello on localhost:8080&lt;/li&gt;
&lt;li&gt;Proxy target: localhost:5000&lt;/li&gt;
&lt;li&gt;Load generator: hey&lt;/li&gt;
&lt;li&gt;Command pattern: hey -n 1000 -c 50 &lt;a href="http://localhost:" rel="noopener noreferrer"&gt;http://localhost:&lt;/a&gt;/api/hello&lt;/li&gt;
&lt;li&gt;Runs per scenario: 3 (median reported)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reproducibility command block (same pattern used for this article):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1) Start Caddy backend&lt;/span&gt;
./caddy.exe run &lt;span class="nt"&gt;--config&lt;/span&gt; Caddyfile

&lt;span class="c"&gt;# 2) Baseline (direct Caddy)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; ./hey &lt;span class="nt"&gt;-n&lt;/span&gt; 1000 &lt;span class="nt"&gt;-c&lt;/span&gt; 50 http://localhost:8080/api/hello | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; baseline-caddy-runs.txt&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# 3) Node proxy benchmark (in another terminal, start proxy first)&lt;/span&gt;
npx chaos-proxy &lt;span class="nt"&gt;--config&lt;/span&gt; chaos.yaml
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; ./hey &lt;span class="nt"&gt;-n&lt;/span&gt; 1000 &lt;span class="nt"&gt;-c&lt;/span&gt; 50 http://localhost:5000/api/hello | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; node-3.0.1-runs.txt&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Stop the Node proxy process before running the Go proxy benchmark (both use port 5000)&lt;/span&gt;

&lt;span class="c"&gt;# 4) Go proxy benchmark (in another terminal, start proxy first)&lt;/span&gt;
./chaos-proxy-go.exe &lt;span class="nt"&gt;--config&lt;/span&gt; chaos.yaml
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; ./hey &lt;span class="nt"&gt;-n&lt;/span&gt; 1000 &lt;span class="nt"&gt;-c&lt;/span&gt; 50 http://localhost:5000/api/hello | &lt;span class="nb"&gt;tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; go-0.2.1-runs.txt&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Versions in this rerun:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;chaos-proxy (Node): 3.0.1&lt;/li&gt;
&lt;li&gt;chaos-proxy-go (Go): 0.2.1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also verified response-size parity for fairness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Caddy: 94 bytes/request&lt;/li&gt;
&lt;li&gt;Node 3.0.1: 94 bytes/request&lt;/li&gt;
&lt;li&gt;Go 0.2.1: 94 bytes/request&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This check mattered because an earlier Node run returned compacted JSON (smaller payload), which could bias throughput. The final numbers below use matched response sizes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Current Rerun (Median of 3)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Requests/sec&lt;/th&gt;
&lt;th&gt;Avg Latency (s)&lt;/th&gt;
&lt;th&gt;P99 Latency (s)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct Caddy&lt;/td&gt;
&lt;td&gt;24,912.1845&lt;/td&gt;
&lt;td&gt;0.0018&lt;/td&gt;
&lt;td&gt;0.0156&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;chaos-proxy Node 3.0.1&lt;/td&gt;
&lt;td&gt;3,788.0065&lt;/td&gt;
&lt;td&gt;0.0129&lt;/td&gt;
&lt;td&gt;0.0318&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;chaos-proxy-go 0.2.1&lt;/td&gt;
&lt;td&gt;7,286.8293&lt;/td&gt;
&lt;td&gt;0.0062&lt;/td&gt;
&lt;td&gt;0.0248&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Old Benchmark Reference (from previous post)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Requests/sec&lt;/th&gt;
&lt;th&gt;Avg Latency (s)&lt;/th&gt;
&lt;th&gt;P99 Latency (s)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direct Caddy&lt;/td&gt;
&lt;td&gt;28,383.8519&lt;/td&gt;
&lt;td&gt;0.0016&lt;/td&gt;
&lt;td&gt;0.0116&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;chaos-proxy Node 2.0.0&lt;/td&gt;
&lt;td&gt;4,262.3420&lt;/td&gt;
&lt;td&gt;0.0115&lt;/td&gt;
&lt;td&gt;0.0417&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;chaos-proxy-go 0.0.5&lt;/td&gt;
&lt;td&gt;8,828.0577&lt;/td&gt;
&lt;td&gt;0.0053&lt;/td&gt;
&lt;td&gt;0.0140&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What changed?
&lt;/h3&gt;

&lt;p&gt;1) Go vs Node in current versions&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Go is still clearly ahead.&lt;/li&gt;
&lt;li&gt;Throughput: Go is about 1.92x higher than Node (7286.8 vs 3788.0 req/sec).&lt;/li&gt;
&lt;li&gt;Average latency: Node is about 2.08x slower than Go (0.0129s vs 0.0062s).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;2) Go old vs Go new&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Throughput decreased from 8828.1 to 7286.8 req/sec (~17.5% lower).&lt;/li&gt;
&lt;li&gt;Average latency increased from 0.0053s to 0.0062s (~17.0% higher).&lt;/li&gt;
&lt;li&gt;P99 increased from 0.0140s to 0.0248s.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;3) Node old vs Node new&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Throughput decreased from 4262.3 to 3788.0 req/sec (~11.1% lower).&lt;/li&gt;
&lt;li&gt;Average latency increased from 0.0115s to 0.0129s (~12.2% higher).&lt;/li&gt;
&lt;li&gt;P99 improved from 0.0417s to 0.0318s.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adding hot-reload-safe runtime mechanics introduces measurable overhead even in steady-state forwarding paths, which is why both implementations are slower than their previous versions in this benchmark shape.&lt;/p&gt;

&lt;p&gt;I did not trigger reloads during benchmark traffic, so this should be interpreted as structural overhead from the runtime architecture needed to guarantee safe reload semantics, not reload execution cost itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why There Is Overhead Even Without Calling /reload
&lt;/h3&gt;

&lt;p&gt;Even if reload is never triggered during the benchmark request stream, the hot reload feature still changes the steady-state architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Requests now run through runtime indirection designed for safe snapshot semantics.&lt;/li&gt;
&lt;li&gt;Runtime objects and routing/middleware composition are organized around swap-ready boundaries.&lt;/li&gt;
&lt;li&gt;Concurrency guards and state-boundary discipline are now part of the normal request path design.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, the cost is not from running /reload repeatedly during the test. The cost comes from maintaining reload-safe invariants all the time.&lt;/p&gt;

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

&lt;p&gt;Implementing the same feature in Node and Go was one of the most useful engineering exercises I have done in a while.&lt;/p&gt;

&lt;p&gt;The final behavior contract can be identical across runtimes, but the implementation pressure points are very different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node emphasizes dynamic composition and careful mutation control.&lt;/li&gt;
&lt;li&gt;Go emphasizes snapshot immutability and explicit concurrency boundaries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Performance-wise, the high-level outcome still holds: the Go proxy remains roughly 2x faster than the Node proxy in this benchmark shape. At the same time, both implementations are now better specified in terms of live reconfiguration semantics, which was the actual feature goal. The implementations are likely not fully performance-tuned yet. For now, that trade-off is acceptable for the feature guarantees we wanted.&lt;/p&gt;

&lt;p&gt;And yes, it was genuinely fun to build.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>go</category>
    </item>
    <item>
      <title>From the Database Zoo to the Database Safari</title>
      <dc:creator>Gabor Koos</dc:creator>
      <pubDate>Tue, 10 Mar 2026 01:46:38 +0000</pubDate>
      <link>https://dev.to/gkoos/from-the-database-zoo-to-the-database-safari-2lo8</link>
      <guid>https://dev.to/gkoos/from-the-database-zoo-to-the-database-safari-2lo8</guid>
      <description>&lt;p&gt;Over the past year I've been writing a series called &lt;a href="https://dev.to/gkoos/series/33483"&gt;The Database Zoo&lt;/a&gt;, exploring the growing ecosystem of modern databases. The idea behind the series was simple: instead of treating "the database" as a single category, look at the different species that exist today - probabilistic databases, time-series systems, vector databases, and more - and understand why they were built and what problems they solve.&lt;/p&gt;

&lt;p&gt;While working on the series, it became clear that the topic deserved a more structured and expanded treatment.&lt;/p&gt;

&lt;p&gt;That work eventually turned into a book.&lt;/p&gt;

&lt;p&gt;I'm currently writing &lt;em&gt;The Database Safari&lt;/em&gt;, to be published by Apress (Springer Nature). The book grows out of the ideas in the Database Zoo series, but develops them into a more cohesive guide to specialized databases: how they work internally, what trade-offs they make, and when they make sense in real systems.&lt;/p&gt;

&lt;p&gt;The book is already listed on SpringerLink:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://link.springer.com/book/9798868827082" rel="noopener noreferrer"&gt;https://link.springer.com/book/9798868827082&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'll share more updates as the writing progresses.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>books</category>
      <category>database</category>
      <category>writing</category>
    </item>
    <item>
      <title>Using Pagination to Improve GraphQL Performance</title>
      <dc:creator>Gabor Koos</dc:creator>
      <pubDate>Thu, 19 Feb 2026 22:42:13 +0000</pubDate>
      <link>https://dev.to/gkoos/using-pagination-to-improve-graphql-performance-4315</link>
      <guid>https://dev.to/gkoos/using-pagination-to-improve-graphql-performance-4315</guid>
      <description>&lt;p&gt;GraphQL makes it easy to request exactly the data you need, but that flexibility can quickly turn into a performance problem when queries return large result sets. A single field that returns "all items" may work fine during development, yet silently degrade into slow responses, high memory usage, or even process crashes as data volume grows.&lt;/p&gt;

&lt;p&gt;This is especially relevant in &lt;strong&gt;Node.js&lt;/strong&gt; backends, where resolvers often materialize entire result sets in memory before returning a response. Fetching a large number of records in a single GraphQL query doesn't just increase response time, it can put sustained pressure on the event loop, garbage collector, and overall process stability.&lt;/p&gt;

&lt;p&gt;Pagination is the standard solution to this problem, but not all pagination strategies behave the same under load.&lt;/p&gt;

&lt;p&gt;In this article, we'll look at three common approaches to pagination in a Node.js GraphQL API: fetching everything at once, &lt;em&gt;offset-based pagination&lt;/em&gt;, and &lt;em&gt;cursor-based pagination&lt;/em&gt;. Rather than treating pagination as a purely theoretical concern, we'll instrument each approach and observe how it affects response times and memory usage.&lt;/p&gt;

&lt;p&gt;We'll build a minimal GraphQL API using &lt;strong&gt;Express&lt;/strong&gt; and &lt;strong&gt;Apollo Server&lt;/strong&gt;, backed by a SQLite database seeded with 500,000 products. You'll see how naive queries show up as slow requests and memory spikes, how offset-based pagination improves things but still has hidden costs, and why cursor-based pagination is - spoiler alert! - the recommended pattern for stable, scalable GraphQL APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up the Project
&lt;/h2&gt;

&lt;p&gt;To keep things simple, the article is accompanied by a runnable &lt;a href="//..."&gt;demo repository&lt;/a&gt;. The project is a small Node.js GraphQL API, with a SQLite backend populated with 500,000 products.&lt;/p&gt;

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

&lt;p&gt;To follow along, you'll only need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 18+&lt;/li&gt;
&lt;li&gt;npm&lt;/li&gt;
&lt;li&gt;A basic understanding of GraphQL and Node.js development&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;Start by cloning the repository and installing dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/gkoos/article-graphql-pagination
&lt;span class="nb"&gt;cd &lt;/span&gt;article-graphql-pagination
npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project uses Prisma with SQLite and includes a seed script that creates 500,000 product records to demonstrate performance differences between pagination strategies.&lt;/p&gt;

&lt;p&gt;Run the following commands to initialize and seed the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx prisma generate
npm run prisma:migrate
npm run prisma:seed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will create the database schema and populate it with realistic-looking product data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running the Server
&lt;/h2&gt;

&lt;p&gt;Once setup is complete, start the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GraphQL API will be available at &lt;code&gt;http://localhost:4000&lt;/code&gt;, where you can explore the schema and run queries using the Apollo Sandbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Fetching All Data at Once
&lt;/h2&gt;

&lt;p&gt;Before introducing pagination, let's start with the simplest approach: returning &lt;em&gt;all&lt;/em&gt; records from a GraphQL field in a single request.&lt;/p&gt;

&lt;p&gt;In our app, the &lt;code&gt;allProducts&lt;/code&gt; query does exactly that. It loads all 500,000 products from the database and returns them as a single response. This kind of query is easy to write, easy to understand, and surprisingly common in early GraphQL schemas.&lt;/p&gt;

&lt;p&gt;Here's the resolver behind it:&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;// src/resolvers.js&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="nx"&gt;allProducts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allProducts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;asc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allProducts&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="s2"&gt;`Fetched &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; products (ALL)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's nothing technically wrong with this resolver. It does exactly what it promises. The problem is &lt;em&gt;scale&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running the Fetch-All Query
&lt;/h3&gt;

&lt;p&gt;Open the GraphQL Sandbox at &lt;code&gt;http://localhost:4000&lt;/code&gt; and run the following query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;allProducts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;allProducts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Depending on your machine, the query may take several seconds to complete. You'll also notice that the response payload is very large: hundreds of thousands of objects serialized into JSON and sent over the wire in one go.&lt;/p&gt;

&lt;p&gt;In your terminal, you should see a log message like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;allProducts: 2.817s
Fetched 500000 products (ALL)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single request:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Executes a large database query&lt;/li&gt;
&lt;li&gt;Allocates memory for all 500,000 rows&lt;/li&gt;
&lt;li&gt;Serializes the entire result set before responding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a real production API, this kind of request can quickly become problematic under concurrent load.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Pattern Breaks Down
&lt;/h3&gt;

&lt;p&gt;Even in this local setup, you can observe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;High response times (often several seconds)&lt;/li&gt;
&lt;li&gt;Significant memory usage spikes during request processing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the resolver loads the entire dataset eagerly, the cost of this query &lt;strong&gt;scales linearly with the number of rows in the table&lt;/strong&gt;. As the dataset grows, so does response time, memory pressure, and GC activity in the Node.js process.&lt;/p&gt;

&lt;p&gt;This is one of those things that often goes unnoticed during development, but becomes very visible once real data and real traffic hit the system.&lt;/p&gt;

&lt;p&gt;Fetching all data at once has a few fundamental problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unbounded results&lt;/strong&gt;: There's no upper limit on how much data a client can request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poor memory characteristics&lt;/strong&gt;: Large result sets must be held in memory until the response is sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unpredictable performance&lt;/strong&gt;: Response time grows with dataset size, not request intent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easy to abuse&lt;/strong&gt;: A single client can unintentionally (or intentionally) stress the backend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pagination&lt;/strong&gt; exists to put boundaries around this behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naive Offset-Based Pagination
&lt;/h2&gt;

&lt;p&gt;A natural first step after realizing that fetching everything at once doesn't scale is to introduce &lt;em&gt;offset-based pagination&lt;/em&gt;. This approach limits the number of records returned per request and allows clients to "page through" results using a combination of limit and offset.&lt;/p&gt;

&lt;p&gt;Offset-based pagination is simple to implement and easy to reason about, which makes it a common choice in REST APIs and an equally common first attempt in GraphQL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementing Offset Pagination
&lt;/h3&gt;

&lt;p&gt;In our demo project, the &lt;code&gt;productsOffset&lt;/code&gt; query exposes this pattern:&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;// src/resolvers.js&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="nx"&gt;productsOffset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;productsOffset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// enforce a maximum limit to prevent abuse&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;take&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;asc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;productsOffset&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="s2"&gt;`Fetched &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; products (offset: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, limit: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing that's important to note here is if we don't limit the number of records returned, we could still end up fetching everything at once. &lt;strong&gt;Always implement a server-side maximum limit to prevent abuse&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The resolver uses Prisma's &lt;code&gt;take&lt;/code&gt; and &lt;code&gt;skip&lt;/code&gt; options to implement limit and offset behavior. Clients can specify how many records they want (&lt;code&gt;limit&lt;/code&gt;) and where to start (&lt;code&gt;offset&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The corresponding GraphQL query looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;productsOffset&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;productsOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;0)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;price&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;category&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of returning all 500,000 products, this query fetches just a small window of results. Clients can request subsequent pages by increasing the offset value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Observing the Improvement
&lt;/h3&gt;

&lt;p&gt;Run the offset-based query a few times from the GraphQL Sandbox, changing the offset to simulate paging through the dataset.&lt;/p&gt;

&lt;p&gt;In your terminal, you should see logs like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;productsOffset: 17.44ms
Fetched 20 products (offset: 0, limit: 20)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compared to the fetch-all approach, you should immediately notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Much faster response times&lt;/li&gt;
&lt;li&gt;Shorter database query time&lt;/li&gt;
&lt;li&gt;Lower overall memory usage per request&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By limiting how many records are loaded and serialized, offset-based pagination dramatically reduces the per-request cost. Even under load, this approach is far more stable than returning everything at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Hidden Cost of Offsets
&lt;/h3&gt;

&lt;p&gt;While offset-based pagination is a clear improvement, it comes with a less obvious downside.&lt;/p&gt;

&lt;p&gt;As the offset value increases, &lt;strong&gt;the database still needs to scan past the skipped rows to reach the requested page&lt;/strong&gt;. For small offsets this isn't a problem, but deeper pages can become increasingly expensive, especially on large tables.&lt;/p&gt;

&lt;p&gt;Let's query the last page of products:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;productsOffset&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;productsOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;499980&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this query and observe the terminal logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;productsOffset: 1.055s
Fetched 20 products (offset: 499980, limit: 20)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this particular case, the first query took 17ms, while the last page took more than a second!&lt;/p&gt;

&lt;p&gt;From the client's perspective, this query looks almost identical to fetching the first page, but from the database's perspective, it may involve scanning hundreds of thousands of rows before returning just 20.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Matters in GraphQL APIs
&lt;/h3&gt;

&lt;p&gt;Offset-based pagination also has semantic issues in GraphQL:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unstable pagination&lt;/strong&gt;: Inserts or deletes can shift offsets, causing clients to skip or duplicate items.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No natural continuation&lt;/strong&gt;: Clients must manage offsets manually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poor fit for infinite scrolling&lt;/strong&gt;: Large offsets become increasingly inefficient.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These limitations are why offset-based pagination is generally considered a transitional solution in GraphQL APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cursor-Based Pagination
&lt;/h2&gt;

&lt;p&gt;Offset-based pagination improves performance by limiting result size, but it still becomes less efficient as clients paginate deeper into a dataset. In GraphQL APIs, the recommended alternative is &lt;strong&gt;cursor-based pagination&lt;/strong&gt;, where each page starts from a known position instead of skipping an arbitrary number of rows.&lt;/p&gt;

&lt;p&gt;Cursor-based pagination is a better fit for large datasets because its &lt;strong&gt;performance depends on page size, not page number&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementing Cursor-Based Pagination
&lt;/h3&gt;

&lt;p&gt;In this project, cursor-based pagination is implemented using Prisma’s native cursor support. Each product's &lt;code&gt;id&lt;/code&gt; is encoded into an opaque cursor, which the client passes back when requesting the next page.&lt;/p&gt;

&lt;p&gt;At a high level, the resolver:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Decodes the after cursor (if present)&lt;/li&gt;
&lt;li&gt;Uses it as a database cursor&lt;/li&gt;
&lt;li&gt;Fetches first + 1 records to determine if another page exists&lt;/li&gt;
&lt;li&gt;Builds a connection-style response with edges and pageInfo&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the resolver implementation:&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;// src/resolvers.js&lt;/span&gt;
&lt;span class="c1"&gt;// Helper function to encode cursor&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;encodeCursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Helper function to decode cursor&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodeCursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ascii&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;...&lt;/span&gt;

&lt;span class="nx"&gt;productsCursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;productsCursor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;decodeCursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Fetch one extra to determine if there's a next page&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;take&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Skip the cursor itself&lt;/span&gt;
        &lt;span class="na"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;asc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasNextPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;edges&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encodeCursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt;
    &lt;span class="p"&gt;}));&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pageInfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hasNextPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;hasPreviousPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;startCursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;endCursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&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;timeEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;productsCursor&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="s2"&gt;`Fetched &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; products (cursor-based, after: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;start&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;pageInfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;totalCount&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach ensures that each query resumes from a precise position in the dataset rather than scanning past thousands of rows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Querying with Cursors
&lt;/h3&gt;

&lt;p&gt;To fetch the first page of products:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cursorProductsFirst&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;productsCursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;pageInfo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;hasNextPage&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;hasPreviousPage&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;startCursor&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;endCursor&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;totalCount&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the terminal, you'll see something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;productsCursor: 39.993ms
Fetched 20 products (cursor-based, after: start)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the response will be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"productsCursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"edges"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"cursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MQ=="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Product 1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;581.7240166646505&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Clothing"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"cursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MjA="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Product 20"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;979.7302196981608&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sports"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"pageInfo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hasNextPage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hasPreviousPage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"startCursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MQ=="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"endCursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MjA="&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"totalCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500000&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The format is slightly different from our offset-based implementation, but all 20 products are returned as expected, plus some useful pagination metadata. To fetch the next page, the client simply uses the &lt;code&gt;endCursor&lt;/code&gt; from the previous response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cursorProductsNext&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;productsCursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MjA="&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;pageInfo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;hasNextPage&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;endCursor&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the response will contain products 21-40:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"productsCursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"edges"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"cursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MjE="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Product 21"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;194.5758511706771&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Toys"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"cursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NDA="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Product 40"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;527.7330156641641&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Electronics"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"pageInfo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hasNextPage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hasPreviousPage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"startCursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MjE="&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"endCursor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NDA="&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"totalCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500000&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for the next page, we would use the new &lt;code&gt;endCursor&lt;/code&gt; value of &lt;code&gt;"NDA="&lt;/code&gt; and so on.&lt;/p&gt;

&lt;p&gt;The cursor itself is opaque to the client and should be treated as an implementation detail. If the client can "guess" cursor values, it may lead to unintended behavior.&lt;/p&gt;

&lt;p&gt;Now let's try to fetch the last page using the cursor! To do this on the client-side, we should keep following the &lt;code&gt;endCursor&lt;/code&gt; values until we reach the end. However, for demonstration purposes, we will cheat a little and directly encode the 499980th product's ID and create a cursor for it. In &lt;code&gt;resolvers.js&lt;/code&gt;, the &lt;code&gt;encodeCursor()&lt;/code&gt; function does this. What we need is &lt;code&gt;Buffer.from("499980").toString("base64")&lt;/code&gt;, which results in &lt;code&gt;NDk5OTgw&lt;/code&gt;, therefore our query to fetch the last page looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cursorProductsNext&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;productsCursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NDk5OTgw"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;pageInfo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;hasNextPage&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="n"&gt;endCursor&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check your terminal logs again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;productsCursor: 35.197ms
Fetched 20 products (cursor-based, after: NDk5OTgw)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As you can see, the response times remain consistent regardless of how deep we paginate into the dataset!&lt;/p&gt;

&lt;p&gt;Compared to offset-based pagination, you should observe:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Consistent database execution time, even for later pages&lt;/li&gt;
&lt;li&gt;Uniform request duration across pages&lt;/li&gt;
&lt;li&gt;Stable memory usage per request&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because each query starts from a known position, the database does not need to scan past large numbers of rows.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Cursor-Based Pagination Scales Better
&lt;/h3&gt;

&lt;p&gt;Cursor-based pagination avoids the main pitfalls of offset-based pagination:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Performance does not degrade as clients paginate deeper&lt;/li&gt;
&lt;li&gt;Pagination remains stable when records are inserted or deleted&lt;/li&gt;
&lt;li&gt;Works naturally with infinite scrolling or stream-like UIs&lt;/li&gt;
&lt;li&gt;Produces predictable, easy-to-compare timings/measurements in observability tools&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Although cursor-based pagination requires slightly more setup than offset-based pagination, it provides far more reliable performance characteristics and is the preferred pattern for production GraphQL APIs.&lt;/p&gt;

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

&lt;p&gt;Pagination is often treated as a schema design detail in GraphQL, but as shown earlier, it has a direct and measurable impact on performance, memory usage, and system stability.&lt;/p&gt;

&lt;p&gt;Fetching all data at once may be convenient, but it quickly becomes a liability as datasets grow. Offset-based pagination improves the situation by limiting result size, yet still introduces hidden costs that surface as users paginate deeper. Cursor-based pagination, on the other hand, provides consistent performance characteristics regardless of dataset size, making it the most reliable choice for production GraphQL APIs.&lt;/p&gt;

&lt;p&gt;More importantly, this article highlights the value of observability-driven decisions. Without instrumentation, all three approaches can appear to "work". But with proper profiling in place, the differences become clear, allowing you to make informed choices about how to design your API for real-world usage patterns.&lt;/p&gt;

&lt;p&gt;If you're building or maintaining a GraphQL API in Node.js, &lt;strong&gt;cursor-based pagination should be your default&lt;/strong&gt; (unless your dataset is small and unlikely to grow). And whatever approach you choose, instrument it early. Pagination is not just about shaping responses: it's about shaping how your system behaves under real-world load.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>graphql</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Lessons Learned from Running a Privacy-First Disposable Email Service: Insights from nullmail.cc</title>
      <dc:creator>Gabor Koos</dc:creator>
      <pubDate>Wed, 18 Feb 2026 22:00:21 +0000</pubDate>
      <link>https://dev.to/gkoos/lessons-learned-from-running-a-privacy-first-disposable-email-service-insights-from-nullmailcc-375g</link>
      <guid>https://dev.to/gkoos/lessons-learned-from-running-a-privacy-first-disposable-email-service-insights-from-nullmailcc-375g</guid>
      <description>&lt;p&gt;A few days ago, I got an unexpected email from Cloudflare: my domain, &lt;code&gt;maildock.store&lt;/code&gt;, had stopped using their nameservers and was at risk of being deleted. This was unexpected, as I hadn't made any changes to the DNS settings. After some investigation, I discovered that the domain had been flagged for abuse, likely due to its association with disposable email services.&lt;/p&gt;

&lt;p&gt;For context, &lt;code&gt;maildock.store&lt;/code&gt; powers &lt;a href="https://nullmail.cc" rel="noopener noreferrer"&gt;nullmail.cc&lt;/a&gt;, a privacy-first disposable email service. Users can create addresses, receive emails, and have them automatically deleted after expiration - all without signing up, tracking, or sending any outgoing mail. The frontend is a &lt;a href="https://kit.svelte.dev/" rel="noopener noreferrer"&gt;SvelteKit&lt;/a&gt; app on &lt;a href="https://vercel.com/" rel="noopener noreferrer"&gt;Vercel&lt;/a&gt;, emails are forwarded via &lt;a href="https://forwardemail.net/" rel="noopener noreferrer"&gt;forwardemail.net&lt;/a&gt; into a &lt;a href="https://supabase.com/" rel="noopener noreferrer"&gt;Supabase&lt;/a&gt; database, and the system automatically cleans up expired content. It's minimal by design, but robust enough to provide a fully functional disposable inbox - mostly in free tiers of various services.&lt;/p&gt;

&lt;p&gt;Despite this simplicity, domains like &lt;code&gt;maildock.store&lt;/code&gt; can trigger automated abuse flags. What followed was a whirlwind of DNS checks, WHOIS lookups, blacklist verification, and conversations with the &lt;code&gt;.store&lt;/code&gt; registry, &lt;a href="https://radix.website" rel="noopener noreferrer"&gt;Radix&lt;/a&gt;. In this post, I'll walk through the story, how (I think) I resolved the issue, and the architectural choices that made it possible to recover safely, while keeping the service privacy-first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design Philosophy
&lt;/h2&gt;

&lt;p&gt;At its core, Nullmail is built around &lt;strong&gt;privacy, simplicity, and minimalism&lt;/strong&gt;. The goal is straightforward: users should be able to receive emails without giving away personal information, signing up for accounts, or being tracked. There's no analytics, no logging beyond what's necessary to deliver emails, and no outgoing SMTP - the service is strictly receive-only.&lt;/p&gt;

&lt;p&gt;Every design choice reflects this philosophy. Addresses are ephemeral and automatically expire, keeping the system lean and reducing the risk of abuse. The database only stores what's necessary: the address itself and the emails sent to it. No unnecessary metadata, no IP logs, no behavioral tracking. Even the UI is stripped down to essentials, giving users just enough functionality to check their inbox and manage addresses.&lt;/p&gt;

&lt;p&gt;This minimal, privacy-first approach has a practical benefit as well: it reduces the attack surface and limits what can go wrong. There's no complicated backend for sending mail, no rate-limiting infrastructure, and no analytics that could trigger false positives with anti-spam systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture Overview
&lt;/h2&gt;

&lt;p&gt;The simplicity of Nullmail's design is mirrored in its architecture. The service combines a few lightweight, well-chosen components to deliver a fully functional disposable email system while staying mostly in free tiers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt;: A SvelteKit app hosted on Vercel. It handles the user interface for reading emails and managing addresses, with minimal JavaScript and no tracking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend/API&lt;/strong&gt;: The same SvelteKit app provides serverless API routes on Vercel for core operations: creating new addresses, listing inboxes, fetching email bodies, and extending expiry timestamps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database&lt;/strong&gt;: A Supabase Postgres instance stores addresses and emails. The database schema is minimal, consisting of:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;addresses table&lt;/strong&gt;: stores the address and its expiry timestamp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;emails table&lt;/strong&gt;: stores sender, recipient, subject, body, and delivery timestamp. Each email references an address, and expired addresses (and their emails) are automatically deleted via a scheduled cron job every five minutes.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Email ingestion&lt;/strong&gt;: All incoming emails are routed through &lt;code&gt;forwardemail.net&lt;/code&gt;. ForwardEmail posts inbound mail to a Vercel API endpoint, which inserts it into the Supabase database.&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Domains &amp;amp; DNS&lt;/strong&gt;: The service operates under the domains &lt;code&gt;maildock.store&lt;/code&gt; and &lt;code&gt;nullmail.cc&lt;/code&gt;. DNS is managed through &lt;a href="https://www.cloudflare.com/" rel="noopener noreferrer"&gt;Cloudflare&lt;/a&gt;, which provides:

&lt;ul&gt;
&lt;li&gt;Nameserver hosting&lt;/li&gt;
&lt;li&gt;MX, SPF, DMARC, TLSRPT, and _security TXT records&lt;/li&gt;
&lt;li&gt;Proxying for web traffic (though Nullmail is mostly static)&lt;/li&gt;
&lt;li&gt;Protection against accidental misconfiguration or abuse flags&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Optional UX&lt;/strong&gt;: Browser extensions for &lt;a href="https://chromewebstore.google.com/detail/nullmail-extension/ogbnjlpdlihcbfmdffhkklhikjlmkfnm?pli=1" rel="noopener noreferrer"&gt;Chrome&lt;/a&gt; and &lt;a href="https://addons.mozilla.org/en-US/firefox/addon/nullmail-extension/" rel="noopener noreferrer"&gt;Firefox&lt;/a&gt; open the site with a &lt;code&gt;fromExtension=1&lt;/code&gt; flag for minor interface tweaks.&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;The codebase can be found on GitHub: &lt;a href="https://github.com/gkoos/nullmail/" rel="noopener noreferrer"&gt;gkoos/nullmail&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This architecture is lightweight but resilient, perfectly aligned with the privacy-first philosophy: no outgoing SMTP, minimal logging, and automated cleanup. It also meant that when the domain was flagged by Radix, most of the infrastructure was unaffected: the problem was isolated to DNS and registry-level issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Incident
&lt;/h2&gt;

&lt;p&gt;Even with a minimal, receive-only setup, disposable email domains can attract attention from automated abuse systems. As I mentioned earlier, I got an email from Cloudflare warning that maildock.store had stopped using their nameservers and was at risk of being deleted. At first, it felt like a false alarm — I hadn't touched any DNS settings.&lt;/p&gt;

&lt;p&gt;Digging deeper, I discovered that the domain had been placed on &lt;code&gt;ServerHold&lt;/code&gt; by Radix, the registry that manages .store domains. &lt;code&gt;ServerHold&lt;/code&gt; is usually reserved for domains flagged for abuse, spam, or other policy violations. In my case, the likely trigger was the domain's association with my disposable email service, even though Nullmail is strictly receive-only and doesn't send outbound mail.&lt;/p&gt;

&lt;p&gt;To investigate, I ran &lt;code&gt;WHOIS&lt;/code&gt; checks, &lt;code&gt;TXT&lt;/code&gt; and &lt;code&gt;MX&lt;/code&gt; lookups, and verified DNS settings through Cloudflare. I also checked common spam/blacklist sources like SURBL: initially, the domain appeared flagged, though later it was cleared. While the frontend and database remained fully functional, the suspension meant that any new email delivery could fail and the domain risked being removed entirely.&lt;/p&gt;

&lt;p&gt;This incident highlighted the harsh reality: even a minimal, privacy-first service with just a few users can run into registry-level issues. Fortunately, the architecture - isolated frontend, serverless backend, and Cloudflare-managed DNS - meant that the problem was largely contained to the domain itself, and recovery would be possible with the right steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Resolution
&lt;/h2&gt;

&lt;p&gt;Once I confirmed that maildock.store was on &lt;code&gt;ServerHold&lt;/code&gt;, the next step was to contact Radix directly via their &lt;a href="https://abuse.radix.website/unsuspension" rel="noopener noreferrer"&gt;unsuspension form&lt;/a&gt;. I explained the service, emphasized it's receive-only nature, and detailed the privacy-first safeguards in place.&lt;/p&gt;

&lt;p&gt;Radix responded positively and removed the &lt;code&gt;ServerHold&lt;/code&gt;, reinstating the domain. To further prevent future issues and provide a point of contact for abuse reports, I created a dedicated abuse mailbox (&lt;a href="mailto:abuse@maildock.store"&gt;abuse@maildock.store&lt;/a&gt;). This mailbox is stored in the Supabase database, has no public access, and is checked only via the Supabase UI as needed.&lt;/p&gt;

&lt;p&gt;Next, I verified and cleaned up the DNS records in Cloudflare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Removed old registrar NS entries that were no longer needed.&lt;/li&gt;
&lt;li&gt;Deleted test or placeholder A records that could confuse DNS checks.&lt;/li&gt;
&lt;li&gt;Confirmed MX records pointing to forwardemail.net were correct.&lt;/li&gt;
&lt;li&gt;Ensured SPF, DMARC, TLSRPT, and &lt;code&gt;_security&lt;/code&gt; TXT records were properly configured, with the abuse mailbox as the contact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After these steps, the domain was fully operational again: emails were being received reliably, and the system continued to enforce automatic cleanup of expired content.&lt;/p&gt;

&lt;p&gt;This experience reinforced the importance of clear abuse contact channels, proper DNS hygiene, and documenting a simple, minimal architecture that isolates potential issues. Even a small, receive-only service can be flagged, but thoughtful design makes recovery straightforward.&lt;/p&gt;

&lt;p&gt;Honestly, I should have anticipated this sooner. Luckily, the solution was simple, and the service is back up without any lasting damage. The incident also provided valuable insights into how registry-level abuse flags work and how to design a service that can recover gracefully from them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;The ServerHold incident with maildock.store offered several insights about running a minimal disposable email service:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Despite being receive-only, with no outgoing mail, maildock.store was still flagged for potential abuse. utomated systems at the registry level tend to be cautious and sometimes overly conservative. Also, because no logs are kept, there's no way to tell what triggered the flag, which is a risk of a privacy-first approach.&lt;/li&gt;
&lt;li&gt;DNS and registry configurations matter more than expected.&lt;/li&gt;
&lt;li&gt;Inconsistencies in nameservers or leftover records may trigger alerts. While the system itself was unaffected, the domain's reachability depends on clear, correct DNS entries.&lt;/li&gt;
&lt;li&gt;Direct communication with the registry is crucial - and they were responsive and helpful in resolving the issue once I provided context about the service and its safeguards.&lt;/li&gt;
&lt;li&gt;Automated abuse flags are often resolvable with context. Having a clear explanation and a point of contact (&lt;a href="mailto:abuse@maildock.store"&gt;abuse@maildock.store&lt;/a&gt;) allowed me to restore the domain quickly.&lt;/li&gt;
&lt;li&gt;Minimalism has trade-offs: a simple architecture isolates most operations from failures like this, but no logging and lack of outbound monitoring means we rely on external feedback to detect issues.&lt;/li&gt;
&lt;li&gt;The "why" remains uncertain: we still don't know exactly what triggered the abuse flag. This leaves open questions about how disposable domains are evaluated, and whether additional safeguards could reduce false positives. And also makes me wonder if the domain was targeted by a malicious actor who reported it, or tried to exploit the service, or if it was just an automated flag based on the domain's history or traffic patterns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Overall, the experience reinforced that running a privacy-first service comes with uncertainties and edge cases. While minimalism and privacy help in some areas, external systems (registries, DNS providers, abuse monitors) can still impact availability in ways that are outside the service's direct control. In a larger context, this highlights the need to do your due diligence and understand the challenges of running a service that interacts with the broader internet ecosystem, even if it's designed to be as simple and private as possible. Also, value your users, but don't trust them to not try to abuse the service, and design with that in mind.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>dns</category>
    </item>
  </channel>
</rss>
