<?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: Juan Torchia</title>
    <description>The latest articles on DEV Community by Juan Torchia (@jtorchia).</description>
    <link>https://dev.to/jtorchia</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%2F885942%2F099b05dc-1940-49f6-a022-9c6a392bb405.jpg</url>
      <title>DEV Community: Juan Torchia</title>
      <link>https://dev.to/jtorchia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jtorchia"/>
    <language>en</language>
    <item>
      <title>Swiper: the touch slider that won't wreck your sprint</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Sat, 11 Jul 2026 12:02:19 +0000</pubDate>
      <link>https://dev.to/jtorchia/swiper-the-touch-slider-that-wont-wreck-your-sprint-291m</link>
      <guid>https://dev.to/jtorchia/swiper-the-touch-slider-that-wont-wreck-your-sprint-291m</guid>
      <description>&lt;p&gt;This is entry #10 of &lt;a href="https://juanchi.dev/en/blog/series/awesome-curated-tools" rel="noopener noreferrer"&gt;Awesome Curated: The Tools&lt;/a&gt;, the series where I dissect the tools that make it through our automated curation system. Today's subject looks simple on the surface but has a lot more depth than you'd expect.&lt;/p&gt;

&lt;p&gt;There's a moment in every web developer's career when the client — or the designer, or the product manager — says: &lt;em&gt;"we need a carousel here."&lt;/em&gt; And something dies inside you. Not because it's technically hard, but because you know exactly what's coming: three days hunting for the right library, five outdated tutorials, a custom implementation that breaks on Samsung Internet, and finally, at 11pm, realizing that touch on iOS feels like dragging a brick across sandpaper.&lt;/p&gt;

&lt;p&gt;That happened to me in 2022, on an e-commerce project with a product catalog that had to work as a carousel on mobile. I tried three libraries before landing on Swiper. The first two had touch behavior that felt like it was pulled from 2013. The third one worked fine but required jQuery — in 2022, jQuery. When I finally opened the Swiper docs and saw the official React wrapper with lazy loading built-in and GPU acceleration, my only thought was: &lt;em&gt;why didn't I start here?&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://github.com/nolimits4web/swiper" rel="noopener noreferrer"&gt;Swiper&lt;/a&gt; is the most complete touch slider/carousel library in the modern web ecosystem. Zero external dependencies. Official wrappers for React, Vue, Angular, and Web Components. Hardware-accelerated transitions that feel native on both iOS and Android.&lt;/p&gt;

&lt;p&gt;We're not talking about a glorified jQuery plugin. Swiper has its own module system that you can import selectively: pagination, arrow navigation, image lazy loading, autoplay, thumbnails, parallax effects, zoom, and a dozen transition styles (fade, cube, flip, cards). All of that without dragging half a third-party library into your bundle.&lt;/p&gt;

&lt;p&gt;The React API is declarative and pretty clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Swiper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SwiperSlide&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;swiper/react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Import only the modules we need — key to keeping the bundle lean&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Navigation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Pagination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Lazy&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;swiper/modules&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;swiper/css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;swiper/css/navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;swiper/css/pagination&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;ProductCarousel&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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Swiper&lt;/span&gt;
      &lt;span class="na"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Navigation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Pagination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Lazy&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;spaceBetween&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;       &lt;span class="c1"&gt;// space between slides in px&lt;/span&gt;
      &lt;span class="na"&gt;slidesPerView&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;     &lt;span class="c1"&gt;// peek at the next slide — classic mobile UX pattern&lt;/span&gt;
      &lt;span class="na"&gt;navigation&lt;/span&gt;                &lt;span class="c1"&gt;// prev/next arrows&lt;/span&gt;
      &lt;span class="na"&gt;pagination&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;clickable&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="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;              &lt;span class="c1"&gt;// native lazy loading — don't load images outside the viewport&lt;/span&gt;
      &lt;span class="na"&gt;breakpoints&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// show more slides on desktop — responsive without extra media queries&lt;/span&gt;
        &lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slidesPerView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slidesPerView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&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;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SwiperSlide&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt;
            &lt;span class="na"&gt;data-src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// data-src for lazy loading&lt;/span&gt;
            &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"swiper-lazy"&lt;/span&gt;
            &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SwiperSlide&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Swiper&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CSS is modular too. If you're not using the scrollbar module, you don't import the scrollbar CSS. Simple as that.&lt;/p&gt;

&lt;p&gt;Under the hood, Swiper uses &lt;code&gt;transform: translate3d()&lt;/code&gt; and &lt;code&gt;will-change&lt;/code&gt; to force the browser to hand off animations to the GPU. The result is that touch scrolling has real inertia — with physical deceleration that respects the OS parameters — instead of the robotic behavior you get with pure CSS or most alternatives.&lt;/p&gt;

&lt;p&gt;The official docs live at &lt;a href="https://swiperjs.com/react" rel="noopener noreferrer"&gt;swiperjs.com&lt;/a&gt; and the &lt;a href="https://github.com/nolimits4web/swiper" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt; has over 39k stars. This isn't recent hype — that consensus has been building for years.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it made the list
&lt;/h2&gt;

&lt;p&gt;Swiper showed up in 4 independent awesome lists. When a specific UI component — not a framework, not a general-purpose library, but &lt;em&gt;a slider&lt;/em&gt; — reaches that level of consensus, it's doing something right.&lt;/p&gt;

&lt;p&gt;The difference between Swiper and alternatives like &lt;a href="https://keen-slider.io/" rel="noopener noreferrer"&gt;Keen-Slider&lt;/a&gt; or &lt;a href="https://www.embla-carousel.com/" rel="noopener noreferrer"&gt;Embla Carousel&lt;/a&gt; isn't that Swiper is objectively better at everything — Embla is noticeably lighter. The difference is that Swiper has the most complete official wrappers, the most exhaustive documentation, and multi-framework support without needing third-party adapters. If you work on a team where there are simultaneous projects in React, Vue, and Angular, having one library that behaves identically across all three is a genuine asset.&lt;/p&gt;

&lt;p&gt;The module system matters too. Before Swiper got this right, the recurring complaint was bundle size. Today, if you import only Navigation and Pagination and nothing else, the bundle impact is much more reasonable. The ~30kb gzip figure that gets thrown around is for the full package with every effect and module included.&lt;/p&gt;

&lt;p&gt;And — this is something I genuinely appreciate after living through some painful migrations over 30 years — the team publishes detailed changelogs and migration guides between major versions. The API changed significantly from v6 to v8 and from v8 to v11, sure, but they don't leave you to figure it out alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to use it
&lt;/h2&gt;

&lt;p&gt;Honest first case: if your carousel has three static slides and you don't need touch, Swiper is probably overkill. That's what pure CSS with &lt;code&gt;scroll-snap-type&lt;/code&gt; is for. Zero JavaScript, zero dependencies, native support in every modern browser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* A basic touch carousel with CSS only — no JS */&lt;/span&gt;
&lt;span class="nc"&gt;.carousel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow-x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;scroll-snap-type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="n"&gt;mandatory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* snap to the nearest slide */&lt;/span&gt;
  &lt;span class="nl"&gt;-webkit-overflow-scrolling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;touch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* inertia on iOS */&lt;/span&gt;
  &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.carousel-slide&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;scroll-snap-align&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* each slide snaps from the start */&lt;/span&gt;
  &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;80%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c"&gt;/* each slide takes up 80% of the width */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second case: if performance is critical and bundle size is genuinely painful, look at &lt;a href="https://www.embla-carousel.com/" rel="noopener noreferrer"&gt;Embla Carousel&lt;/a&gt;. It's smaller, more extensible by design, and takes the "bring only what you need" philosophy to its logical extreme. The trade-off is that the API is lower-level and requires more configuration for things that in Swiper are a single prop.&lt;/p&gt;

&lt;p&gt;Third case, and this is the most important one: question whether you actually need a carousel at all. There's substantial UX evidence suggesting that carousels reduce engagement on landing pages. Not because Swiper is the problem — the &lt;em&gt;pattern itself&lt;/em&gt; can be the problem. When a client asks for a carousel, ask what problem they're actually trying to solve. Sometimes there's a better answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Swiper made it through the curation system's filter for the right reasons: real community consensus, active maintenance, and a clear value proposition that holds up under technical scrutiny. It's not magic, but it does what it promises better than almost any alternative.&lt;/p&gt;

&lt;p&gt;This is entry #10 of &lt;a href="https://juanchi.dev/en/blog/series/awesome-curated-tools" rel="noopener noreferrer"&gt;Awesome Curated: The Tools&lt;/a&gt;. If you landed here directly and missed the rest of the series, there are posts worth checking out: the one on &lt;a href="https://juanchi.dev/en/blog/sniffnet-monitor-network-traffic-without-tcpdump" rel="noopener noreferrer"&gt;Sniffnet for network monitoring&lt;/a&gt; and the one on &lt;a href="https://juanchi.dev/en/blog/nodejs-runtime-that-changed-backend-forever" rel="noopener noreferrer"&gt;Node.js we kicked off last week&lt;/a&gt; are solid entry points if you're interested in the tooling ecosystem beyond ML. The next post in the series follows the same criteria: a tool that appeared in multiple lists, passed the automated filter, and that I reviewed personally before writing a single word about it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://juanchi.dev/en/blog/swiper-touch-slider-carousel-react-vue-angular" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>english</category>
      <category>react</category>
      <category>opensource</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Swiper: el slider táctil que no te va a arruinar el sprint</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Sat, 11 Jul 2026 12:02:14 +0000</pubDate>
      <link>https://dev.to/jtorchia/swiper-el-slider-tactil-que-no-te-va-a-arruinar-el-sprint-11ek</link>
      <guid>https://dev.to/jtorchia/swiper-el-slider-tactil-que-no-te-va-a-arruinar-el-sprint-11ek</guid>
      <description>&lt;p&gt;Esta es la entrega #10 de &lt;a href="https://juanchi.dev/es/blog/series/awesome-curated-tools" rel="noopener noreferrer"&gt;Awesome Curated: The Tools&lt;/a&gt;, la serie donde disecciono las herramientas que pasan el filtro de nuestro sistema de curación automático. Hoy le toca a algo que parece simple pero que tiene más profundidad de la que se ve a primera vista.&lt;/p&gt;

&lt;p&gt;Hay un momento en la carrera de todo desarrollador web donde el cliente —o el diseñador, o el product manager— te dice: &lt;em&gt;"necesitamos un carousel acá"&lt;/em&gt;. Y en ese momento, algo muere adentro tuyo. No porque sea difícil técnicamente, sino porque sabés lo que viene: tres días buscando la librería correcta, cinco tutoriales desactualizados, una implementación propia que queda rota en Samsung Internet, y finalmente, a las 11 de la noche, dándote cuenta de que el touch en iOS se siente como arrastrar un ladrillo.&lt;/p&gt;

&lt;p&gt;Me pasó en 2022, trabajando en un proyecto de e-commerce con un catálogo de productos que tenía que funcionar como carrusel en mobile. Probé tres librerías antes de llegar a Swiper. Las primeras dos tenían comportamiento táctil que parecía sacado de 2013. La tercera funcionaba bien pero requería jQuery. Cuando finalmente abrí la doc de Swiper y vi el wrapper oficial para React, con lazy loading built-in y aceleración por GPU, pensé: &lt;em&gt;¿por qué no empecé acá?&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué hace
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/nolimits4web/swiper" rel="noopener noreferrer"&gt;Swiper&lt;/a&gt; es la librería de sliders/carousels táctiles más completa del ecosistema web moderno. Cero dependencias externas. Wrappers oficiales para React, Vue, Angular y Web Components. Transiciones aceleradas por hardware que en iOS y Android se sienten como si fueran nativas.&lt;/p&gt;

&lt;p&gt;No estamos hablando de un jQuery plugin glorificado. Swiper tiene su propio sistema de módulos que podés importar selectivamente: paginación, navegación con flechas, lazy loading de imágenes, autoplay, thumbnails, efectos de parallax, zoom, y una docena de transiciones (fade, cube, flip, cards). Todo esto sin meter media librería de terceros en el bundle.&lt;/p&gt;

&lt;p&gt;La API en React es declarativa y bastante limpia:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Swiper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SwiperSlide&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;swiper/react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// Importamos solo los módulos que necesitamos — clave para no inflar el bundle&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Navigation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Pagination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Lazy&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;swiper/modules&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;swiper/css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;swiper/css/navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;swiper/css/pagination&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;ProductCarousel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;productos&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Swiper&lt;/span&gt;
      &lt;span class="na"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;Navigation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Pagination&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Lazy&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;spaceBetween&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;       &lt;span class="c1"&gt;// espacio entre slides en px&lt;/span&gt;
      &lt;span class="na"&gt;slidesPerView&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;     &lt;span class="c1"&gt;// mostramos un poco del próximo slide (patrón UX clásico en mobile)&lt;/span&gt;
      &lt;span class="na"&gt;navigation&lt;/span&gt;                &lt;span class="c1"&gt;// flechas prev/next&lt;/span&gt;
      &lt;span class="na"&gt;pagination&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;clickable&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="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;              &lt;span class="c1"&gt;// lazy loading nativo — no cargamos imágenes fuera del viewport&lt;/span&gt;
      &lt;span class="na"&gt;breakpoints&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// en desktop mostramos más slides — responsive sin media queries extra&lt;/span&gt;
        &lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slidesPerView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slidesPerView&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;productos&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;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SwiperSlide&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;img&lt;/span&gt;
            &lt;span class="na"&gt;data-src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;imagen&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// data-src para lazy loading&lt;/span&gt;
            &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"swiper-lazy"&lt;/span&gt;
            &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nombre&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nombre&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SwiperSlide&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Swiper&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El CSS lo podés importar modularmente también. Si no usás el módulo de scrollbar, no importás el CSS de scrollbar. Simple.&lt;/p&gt;

&lt;p&gt;Por abajo, Swiper usa &lt;code&gt;transform: translate3d()&lt;/code&gt; y &lt;code&gt;will-change&lt;/code&gt; para forzar que el navegador delegue las animaciones a la GPU. El resultado es que el scroll táctil tiene inercia real —con deceleración física que respeta los parámetros del sistema operativo— en lugar del comportamiento robótico que tenés con CSS puro o la mayoría de las alternativas.&lt;/p&gt;

&lt;p&gt;El sitio oficial con la documentación completa está en &lt;a href="https://swiperjs.com/react" rel="noopener noreferrer"&gt;swiperjs.com&lt;/a&gt; y el repo en &lt;a href="https://github.com/nolimits4web/swiper" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; tiene más de 39k estrellas. No es hype reciente: lleva años acumulando ese consenso.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por qué está en la lista
&lt;/h2&gt;

&lt;p&gt;Swiper apareció en 4 awesome lists independientes. Cuando un componente de UI específico —no un framework, no una librería de propósito general, sino &lt;em&gt;un slider&lt;/em&gt;— logra ese nivel de consenso, algo está haciendo bien.&lt;/p&gt;

&lt;p&gt;La diferencia con alternativas como &lt;a href="https://keen-slider.io/" rel="noopener noreferrer"&gt;Keen-Slider&lt;/a&gt; o &lt;a href="https://www.embla-carousel.com/" rel="noopener noreferrer"&gt;Embla Carousel&lt;/a&gt; no es que Swiper sea objetivamente mejor en todo —Embla es notablemente más liviano— sino que Swiper tiene los wrappers oficiales más completos, la documentación más exhaustiva, y el soporte multi-framework sin necesitar adaptadores de terceros. Si trabajás en un equipo donde hay proyectos en React, Vue y Angular simultáneamente, tener una sola librería que funciona igual en los tres es un activo real.&lt;/p&gt;

&lt;p&gt;El sistema de módulos también importa. Antes de que Swiper lo implementara bien, la queja recurrente era el bundle size. Hoy, si importás solo Navigation y Pagination sin nada más, el impacto en el bundle es mucho más razonable. El ~30kb gzip que figura como número de referencia es para el paquete completo con todos los efectos y módulos.&lt;/p&gt;

&lt;p&gt;Además —y esto es algo que aprecio mucho como alguien que vivió migraciones dolorosas— el equipo publica changelogs detallados y guías de migración entre versiones mayores. La API cambió bastante de v6 a v8 y de v8 a v11, sí, pero no te dejan solos en el proceso.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cuándo NO usarlo
&lt;/h2&gt;

&lt;p&gt;Primer caso honesto: si tu carousel tiene tres slides fijos y no necesitás touch, probablemente Swiper sea overkill. Para eso existe CSS puro con &lt;code&gt;scroll-snap-type&lt;/code&gt;. Cero JavaScript, cero dependencias, soporte nativo en todos los browsers modernos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Un carousel básico táctil solo con CSS — sin JS */&lt;/span&gt;
&lt;span class="nc"&gt;.carousel&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overflow-x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;scroll-snap-type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="n"&gt;mandatory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* snap al slide más cercano */&lt;/span&gt;
  &lt;span class="nl"&gt;-webkit-overflow-scrolling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;touch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* inercia en iOS */&lt;/span&gt;
  &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.carousel-slide&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;scroll-snap-align&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* cada slide hace snap desde el inicio */&lt;/span&gt;
  &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;80%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;             &lt;span class="c"&gt;/* cada slide ocupa el 80% del ancho */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Segundo caso: si el performance es crítico y el bundle size te duele, mirá &lt;a href="https://www.embla-carousel.com/" rel="noopener noreferrer"&gt;Embla Carousel&lt;/a&gt;. Es más pequeño, más extensible por diseño, y tiene una filosofía de "traé solo lo que necesitás" llevada al extremo. La contra es que la API es más de bajo nivel y requiere más configuración para cosas que en Swiper son un prop.&lt;/p&gt;

&lt;p&gt;Tercer caso, el más importante: cuestioná si realmente necesitás un carousel. Hay bastante evidencia de UX que sugiere que los carousels reducen el engagement en landing pages. No porque Swiper sea el problema, sino porque el patrón mismo puede ser problemático. Si el cliente te pide un carousel, preguntá qué problema quiere resolver — a veces hay una respuesta mejor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cierre
&lt;/h2&gt;

&lt;p&gt;Swiper pasó el filtro del sistema de curación por las razones correctas: consenso real de la comunidad, mantenimiento activo, y una propuesta de valor clara que resiste el escrutinio técnico. No es magia, pero hace lo que promete mejor que casi cualquier alternativa.&lt;/p&gt;

&lt;p&gt;Esta es la entrega #10 de &lt;a href="https://juanchi.dev/es/blog/series/awesome-curated-tools" rel="noopener noreferrer"&gt;Awesome Curated: The Tools&lt;/a&gt;. Si llegaste acá directo y no viste el resto de la serie, hay posts que vale la pena revisar: el de &lt;a href="https://juanchi.dev/es/blog/sniffnet-monitor-trafico-red-ui-accesible-rust" rel="noopener noreferrer"&gt;Sniffnet para monitoreo de red&lt;/a&gt; y el de &lt;a href="https://juanchi.dev/es/blog/nodejs-runtime-javascript-backend-event-loop-ecosystem" rel="noopener noreferrer"&gt;Node.js que arrancamos la semana pasada&lt;/a&gt; son buenos puntos de entrada si te interesa el ecosistema de herramientas más allá del ML. El próximo post de la serie sigue el mismo criterio: una herramienta que apareció en múltiples listas, que pasó el filtro automatizado, y que yo revisé personalmente antes de escribir una sola palabra sobre ella.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Este artículo fue publicado originalmente en &lt;a href="https://juanchi.dev/es/blog/swiper-slider-tactil-mobile-react-vue-angular-sin-dependencias" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>espanol</category>
      <category>react</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Node.js: the runtime that changed how we think about backend</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Wed, 08 Jul 2026 12:01:59 +0000</pubDate>
      <link>https://dev.to/jtorchia/nodejs-the-runtime-that-changed-how-we-think-about-backend-116o</link>
      <guid>https://dev.to/jtorchia/nodejs-the-runtime-that-changed-how-we-think-about-backend-116o</guid>
      <description>&lt;p&gt;It was 2012 and I was buried up to my ears in Linux servers, babysitting LAMP stacks that crawled under load. A colleague dropped a benchmark on my desk: a Node.js server handling 10,000 concurrent connections with a single process consuming less memory than Apache under 500. I read it three times. I was sure it was wrong. It wasn't wrong.&lt;/p&gt;

&lt;p&gt;I didn't adopt it then — I stayed in my world of infrastructure and Java. But the seed was planted. When I made my definitive pivot to software development in 2021, Node was everywhere. And when I truly understood it — not just how to use it but &lt;em&gt;why it works the way it does&lt;/em&gt; — everything clicked. I finally got why it had made so much noise.&lt;/p&gt;

&lt;p&gt;This is post #9 in the &lt;a href="https://juanchi.dev/en/blog/series/awesome-curated-tools" rel="noopener noreferrer"&gt;Awesome Curated: The Tools&lt;/a&gt; series, where I dig into tools that pass the filter of our automated curation system. Node.js came up through Sindre Sorhus's &lt;a href="https://github.com/sindresorhus/awesome-nodejs" rel="noopener noreferrer"&gt;awesome-nodejs&lt;/a&gt; list — which is basically a universe unto itself — and crossed with a signal in 4 independent awesome lists. That doesn't happen by accident.&lt;/p&gt;

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

&lt;p&gt;Node.js is a JavaScript runtime built on Chrome's V8 engine, but that's the boring technical description. What makes it special is the &lt;strong&gt;non-blocking event loop&lt;/strong&gt;. In a traditional web server, every incoming request waits: waits for the database to respond, waits for the disk to read a file, waits for the network to return something. While it waits, that thread is blocked — sitting there doing absolutely nothing useful. Multiply that by thousands of connections and you understand why you needed 64GB of RAM just to run a decent server.&lt;/p&gt;

&lt;p&gt;Node flips the model. When you kick off an I/O operation, Node registers a callback and moves on. When the disk finishes reading, when the DB responds, when the socket has data — Node knows, and executes what comes next. A single thread managing thousands of in-flight operations simultaneously. It's not magic, it's a different mental model.&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;// The traditional model (blocking): you wait at every step&lt;/span&gt;
&lt;span class="c1"&gt;// The Node model: you register what you want to happen WHEN something is ready&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&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;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// This does NOT block the event loop&lt;/span&gt;
  &lt;span class="c1"&gt;// Node keeps handling other requests while the disk reads&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./data.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&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;err&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Something blew up&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="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Only now do we respond, when the data is actually available&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;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&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="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Listening on port 3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Modern async/await syntax makes this a lot more readable today:&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;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;promises&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;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Await doesn't block the event loop — it releases the thread while it waits&lt;/span&gt;
    &lt;span class="c1"&gt;// Other requests keep getting processed in parallel&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./data.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Could not read the file&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;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The npm ecosystem is a whole other beast. Over 2 million packages. It's simultaneously an absurd superpower and a security nightmare vector (left-pad, event-stream, the ghosts of the past). But the point is: for almost anything you need, there's a library. &lt;a href="https://github.com/nodejs/node" rel="noopener noreferrer"&gt;You can find the official repository here&lt;/a&gt; and Sindre Sorhus's awesome-nodejs &lt;a href="https://github.com/sindresorhus/awesome-nodejs" rel="noopener noreferrer"&gt;here&lt;/a&gt; — that second URL is practically a map of the entire ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's on the list
&lt;/h2&gt;

&lt;p&gt;Four independent awesome lists pointing at Node.js is community consensus, not hype. And it makes sense: Node solved a real problem that backend development had pre-2009 — the C10K problem, the difficulty of handling 10,000 concurrent connections with traditional servers. Ryan Dahl didn't invent the event loop, but he took an idea that already existed in Nginx and put it in the hands of JavaScript developers, who were already tens of millions strong.&lt;/p&gt;

&lt;p&gt;The most important unlock was &lt;strong&gt;unifying the language&lt;/strong&gt;. Before Node, if you were a web dev, you knew JavaScript in the browser and something else (PHP, Ruby, Java) on the server. With Node, same language, same paradigms, same team. That had an enormous organizational impact that you can still feel today in the job market.&lt;/p&gt;

&lt;p&gt;Compared to contemporary alternatives: Go wins on raw performance and simplicity of its concurrency model. Python with FastAPI or Django is more readable for many use cases. Java with Spring Boot — my current world — has enterprise maturity and tooling that Node is still building toward. But none of them have the npm ecosystem, none of them have such a low barrier to entry for someone who already knows JavaScript, and none of them moved the developer tooling market as fast — Webpack, Babel, ESLint, Prettier, they all run on Node.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to use it
&lt;/h2&gt;

&lt;p&gt;If you've got CPU-bound workloads — image processing, heavy cryptography, machine learning, compilation — Node will let you down. The event loop is brilliant for concurrent I/O, but the single thread becomes a bottleneck the moment there's serious computation involved. Worker Threads mitigate this, but at that point you're fighting against the runtime's own design. For those cases, Python with multiprocessing, Go, or Java are more natural fits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory leaks&lt;/strong&gt; are more frequent and harder to debug than in compiled languages. V8's garbage collector is good, but if you don't deeply understand the model of closures and circular references, you'll watch processes grow in memory without stopping until they explode in production at 3am. I'm speaking from personal experience. Also: if your team doesn't have solid experience with async programming, callback hell and the subtle bugs of async/await can produce code that's genuinely painful to maintain. In those cases, a more opinionated framework like &lt;a href="https://spring.io/projects/spring-boot" rel="noopener noreferrer"&gt;Spring Boot&lt;/a&gt; or &lt;a href="https://www.djangoproject.com/" rel="noopener noreferrer"&gt;Django&lt;/a&gt; might be healthier in the long run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;Node.js is one of those tools that permanently changed the ecosystem. Not because it's perfect — it isn't — but because it arrived at exactly the right moment with exactly the right idea, and pulled millions of developers along with it. Thirty years of watching technology come and go taught me to tell the difference between hype that fades and real change. Node is the latter.&lt;/p&gt;

&lt;p&gt;If this post was useful, the &lt;a href="https://juanchi.dev/en/blog/series/awesome-curated-tools" rel="noopener noreferrer"&gt;Awesome Curated: The Tools&lt;/a&gt; series has 8 more posts where I analyze tools with the same lens. From &lt;a href="https://juanchi.dev/en/blog/themis-serious-cryptography-without-losing-your-mind" rel="noopener noreferrer"&gt;cryptography with Themis&lt;/a&gt; to &lt;a href="https://juanchi.dev/en/blog/sniffnet-monitor-network-traffic-without-tcpdump" rel="noopener noreferrer"&gt;network monitoring with Sniffnet&lt;/a&gt; — each one passed the same curation filter before landing here. The next one will too.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://juanchi.dev/en/blog/nodejs-runtime-that-changed-backend-forever" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>english</category>
      <category>javascript</category>
      <category>node</category>
      <category>backend</category>
    </item>
    <item>
      <title>Node.js: el runtime que cambió cómo pensamos el backend</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Wed, 08 Jul 2026 12:01:54 +0000</pubDate>
      <link>https://dev.to/jtorchia/nodejs-el-runtime-que-cambio-como-pensamos-el-backend-1e86</link>
      <guid>https://dev.to/jtorchia/nodejs-el-runtime-que-cambio-como-pensamos-el-backend-1e86</guid>
      <description>&lt;p&gt;Corría 2012 y yo estaba metido hasta las orejas en servidores Linux, administrando stacks LAMP que respondían lento bajo carga. Un colega me pasó un benchmark: un servidor Node.js manejando 10.000 conexiones concurrentes con un proceso que consumía menos memoria que un Apache con 500. Lo miré tres veces. Pensé que era mentira. No era mentira.&lt;/p&gt;

&lt;p&gt;En ese momento no lo adopté — seguí con mi mundo de infraestructura y Java. Pero la semilla quedó. Cuando en 2021 hice el pivot definitivo al desarrollo de software, Node estaba en todos lados. Y cuando lo entendí de verdad — no solo cómo usarlo sino &lt;em&gt;por qué funciona así&lt;/em&gt; — me cayó la ficha de por qué había generado tanto ruido.&lt;/p&gt;

&lt;p&gt;Este es el post #9 de la serie &lt;a href="https://juanchi.dev/es/blog/series/awesome-curated-tools" rel="noopener noreferrer"&gt;Awesome Curated: The Tools&lt;/a&gt;, donde analizo herramientas que pasan el filtro de nuestro sistema de curación automático. Node.js apareció referenciado desde la lista &lt;a href="https://github.com/sindresorhus/awesome-nodejs" rel="noopener noreferrer"&gt;awesome-nodejs&lt;/a&gt; de Sindre Sorhus — que por sí sola es un universo — y cruzó con señal en 4 awesome lists independientes. Eso no pasa por casualidad.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué hace
&lt;/h2&gt;

&lt;p&gt;Node.js es un runtime de JavaScript construido sobre el motor V8 de Chrome, pero eso es la descripción técnica aburrida. Lo que lo hace especial es el &lt;strong&gt;event loop no-bloqueante&lt;/strong&gt;. En un servidor web tradicional, cada request que llega espera: espera que la base de datos responda, espera que el disco lea un archivo, espera que la red devuelva algo. Mientras espera, ese hilo está bloqueado, sin hacer nada útil. Multiplicá eso por miles de conexiones y entendés por qué necesitabas 64GB de RAM para un servidor decente.&lt;/p&gt;

&lt;p&gt;Node invierte el modelo. Cuando hacés una operación I/O, Node registra un callback y sigue adelante. Cuando el disco termina de leer, cuando la DB responde, cuando el socket tiene datos — Node lo sabe y ejecuta lo que sigue. Un solo hilo manejando miles de operaciones en vuelo simultáneamente. No es magia, es un modelo mental diferente.&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;// El modelo tradicional (bloqueante): esperás en cada paso&lt;/span&gt;
&lt;span class="c1"&gt;// El modelo Node: registrás lo que querés que pase CUANDO algo esté listo&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&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;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Esto NO bloquea el event loop&lt;/span&gt;
  &lt;span class="c1"&gt;// Node sigue atendiendo otras requests mientras el disco lee&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./data.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&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;err&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Algo explotó&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="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// Recién acá respondemos, cuando el dato está disponible&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;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&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="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Escuchando en puerto 3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hoy la sintaxis moderna con async/await hace esto mucho más legible:&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;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;promises&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;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Await no bloquea el event loop — libera el hilo mientras espera&lt;/span&gt;
    &lt;span class="c1"&gt;// Otros requests siguen siendo procesados en paralelo&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./data.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No se pudo leer el archivo&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;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El ecosistema npm es otro animal completamente. Más de 2 millones de paquetes. Es tanto una fortaleza absurda como un vector de quilombos de seguridad (left-pad, event-stream, los fantasmas del pasado). Pero el punto es que para casi cualquier cosa que necesitás, existe una librería. &lt;a href="https://github.com/nodejs/node" rel="noopener noreferrer"&gt;Podés ver el repositorio oficial acá&lt;/a&gt; y la awesome-nodejs de Sindre Sorhus &lt;a href="https://github.com/sindresorhus/awesome-nodejs" rel="noopener noreferrer"&gt;acá&lt;/a&gt; — esa segunda URL es prácticamente un mapa del ecosistema completo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por qué está en la lista
&lt;/h2&gt;

&lt;p&gt;Cuatro awesome lists independientes señalando Node.js es consenso de comunidad, no hype. Y tiene sentido: Node resolvió un problema real que tenía el desarrollo backend pre-2009 — la C10K problem, la dificultad de manejar 10.000 conexiones concurrentes con servidores tradicionales. Ryan Dahl no inventó el event loop, pero tomó una idea que existía en Nginx y la puso en manos de developers JavaScript que ya eran millones.&lt;/p&gt;

&lt;p&gt;El unlock más importante fue &lt;strong&gt;unificar el lenguaje&lt;/strong&gt;. Antes de Node, si eras dev web, sabías JavaScript en el browser y algo más (PHP, Ruby, Java) en el server. Con Node, el mismo lenguaje, los mismos paradigmas, el mismo equipo. Eso tuvo un impacto organizacional enorme que todavía se siente hoy en el mercado laboral.&lt;/p&gt;

&lt;p&gt;Comparado con alternativas contemporáneas: Go gana en raw performance y simplicidad del modelo de concurrencia. Python con FastAPI o Django es más legible para muchos casos. Java con Spring Boot (mi mundo actual) tiene una madurez y tooling empresarial que Node todavía está construyendo. Pero ninguno de ellos tiene el ecosistema npm, ninguno tiene la barrera de entrada tan baja para alguien que ya sabe JavaScript, y ninguno movió tan rápido el mercado de herramientas de desarrollo — Webpack, Babel, ESLint, Prettier, todos corren sobre Node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cuándo NO usarlo
&lt;/h2&gt;

&lt;p&gt;Si tenés CPU-bound workloads — procesamiento de imágenes, criptografía pesada, machine learning, compilación — Node te va a defraudar. El event loop es brillante para I/O concurrente pero el single thread se convierte en cuello de botella cuando hay cómputo intensivo. Los Worker Threads mitigan esto, pero en ese punto ya estás peleando contra el diseño del runtime. Para eso, Python con multiprocessing, Go, o Java son opciones más naturales.&lt;/p&gt;

&lt;p&gt;Los &lt;strong&gt;memory leaks&lt;/strong&gt; son más frecuentes y más difíciles de debuggear que en lenguajes compilados. El garbage collector de V8 es bueno, pero si no entendés el modelo de closures y referencias circulares, vas a ver procesos que crecen de memoria sin parar hasta que explotan en producción a las 3 de la mañana. Hablo por experiencia propia. También: si tu equipo no tiene experiencia sólida con programación asíncrona, el callback hell y los errores sutiles de async/await pueden generar código muy difícil de mantener. En esos casos, un framework con más estructura opinionada como &lt;a href="https://spring.io/projects/spring-boot" rel="noopener noreferrer"&gt;Spring Boot&lt;/a&gt; o &lt;a href="https://www.djangoproject.com/" rel="noopener noreferrer"&gt;Django&lt;/a&gt; puede ser más sano a largo plazo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cierre
&lt;/h2&gt;

&lt;p&gt;Node.js es una de esas herramientas que cambió el ecosistema de forma permanente. No porque sea perfecta — no lo es — sino porque llegó en el momento justo con la idea correcta y arrastró a millones de developers con ella. Treinta años viendo tecnología me enseñaron a distinguir el hype que se desvanece del cambio real. Node es lo segundo.&lt;/p&gt;

&lt;p&gt;Si este post te resultó útil, la serie &lt;a href="https://juanchi.dev/es/blog/series/awesome-curated-tools" rel="noopener noreferrer"&gt;Awesome Curated: The Tools&lt;/a&gt; tiene 8 posts más donde analizo herramientas con el mismo criterio. Desde &lt;a href="https://juanchi.dev/es/blog/themis-criptografia-alto-nivel-sin-openssl" rel="noopener noreferrer"&gt;criptografía con Themis&lt;/a&gt; hasta &lt;a href="https://juanchi.dev/es/blog/sniffnet-monitor-trafico-red-ui-accesible-rust" rel="noopener noreferrer"&gt;monitoreo de red con Sniffnet&lt;/a&gt; — cada una pasó el mismo filtro de curación antes de llegar acá. El próximo también.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Este artículo fue publicado originalmente en &lt;a href="https://juanchi.dev/es/blog/nodejs-runtime-javascript-backend-event-loop-ecosystem" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>espanol</category>
      <category>javascript</category>
      <category>node</category>
    </item>
    <item>
      <title>Rate limiting in web apps: what to protect before picking a library</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Tue, 07 Jul 2026 12:00:52 +0000</pubDate>
      <link>https://dev.to/jtorchia/rate-limiting-in-web-apps-what-to-protect-before-picking-a-library-4oc2</link>
      <guid>https://dev.to/jtorchia/rate-limiting-in-web-apps-what-to-protect-before-picking-a-library-4oc2</guid>
      <description>&lt;h1&gt;
  
  
  Rate limiting in web apps: what to protect before picking a library
&lt;/h1&gt;

&lt;p&gt;I spent months convinced that rate limiting was just a matter of adding middleware, setting a request-per-minute number, and moving on. It took seeing a login endpoint with zero controls — in a reference project I was using to study architecture — to understand that the missing library wasn't the problem. Nobody had defined what was being protected, from what kind of abuse, and at what cost.&lt;/p&gt;

&lt;p&gt;I'm not telling that story to look bad. I'm telling it because you're probably going to copy that &lt;code&gt;upstash/ratelimit&lt;/code&gt; or &lt;code&gt;express-rate-limit&lt;/code&gt; snippet before you ask yourself that question. And if you do, you'll have rate limiting that works on paper and fails exactly where it matters most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My take:&lt;/strong&gt; rate limiting is not a dependency. It's an abuse policy. And a policy without context is decoration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rate limiting in Next.js web apps: the real problem
&lt;/h2&gt;

&lt;p&gt;Most rate limiting tutorials for Next.js start from the library. They show you how to configure &lt;code&gt;Upstash Redis&lt;/code&gt;, how many tokens to put in the bucket, and how to return a &lt;code&gt;429&lt;/code&gt;. All of that is fine. The problem is that sequence skips four questions that should come first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What asset are you protecting?&lt;/strong&gt; A login endpoint? A public API with no auth? A contact form? A route that fires an email? They don't all deserve the same policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What kind of abuse are you expecting?&lt;/strong&gt; Credential stuffing, scraping, form spam, and DDoS are four different problems. Rate limiting solves some of them, not all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does a false positive cost you?&lt;/strong&gt; Blocking a legitimate user at an e-commerce checkout has a very different cost than blocking a bot on a public query API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How will you know it's working?&lt;/strong&gt; Without logs, without metrics, without alerts, the middleware acts in silence and you have no idea whether it's blocking what it should or what it shouldn't.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These four points aren't philosophical. They're the difference between a control that actually protects and one that gives you a false sense of security.&lt;/p&gt;




&lt;h2&gt;
  
  
  What OWASP says — and what it doesn't
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Authentication Cheat Sheet&lt;/a&gt; is the most-cited public reference when talking about defensive controls around authentication and abuse. Worth reading directly instead of trusting someone else's summary.&lt;/p&gt;

&lt;p&gt;What OWASP explicitly says in that document:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Implement controls to limit failed login attempts to mitigate brute force attacks.&lt;/li&gt;
&lt;li&gt;Consider temporary lockout, CAPTCHA, or user notification as possible responses.&lt;/li&gt;
&lt;li&gt;Aggressive lockout can itself be used as a denial-of-service vector against legitimate users — meaning the false positive has a security cost, not just a UX one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What OWASP &lt;strong&gt;doesn't&lt;/strong&gt; say in that document:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many requests per minute is the right number. That number doesn't exist outside of context.&lt;/li&gt;
&lt;li&gt;That IP-based rate limiting is sufficient. An attacker with distributed infrastructure rotates IPs trivially.&lt;/li&gt;
&lt;li&gt;That every route in an application deserves the same control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters because a lot of people cite "OWASP recommends rate limiting" as if it were a universal recipe. It isn't. It's one control among several, with explicit preconditions and trade-offs.&lt;/p&gt;

&lt;p&gt;If you've already read my post on &lt;a href="https://juanchi.dev/en/blog/spring-boot-actuator-endpoints-security-expose-hide" rel="noopener noreferrer"&gt;what to expose and what to hide in Spring Boot Actuator&lt;/a&gt;, you'll recognize the pattern: the problem isn't enabling or disabling a feature. It's understanding what decision you're making and why.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where the common recipe goes wrong
&lt;/h2&gt;

&lt;p&gt;The typical setup in Next.js App Router looks like this: install &lt;code&gt;@upstash/ratelimit&lt;/code&gt;, wrap the Route Handler or Server Action, configure 10 requests per minute per IP, return &lt;code&gt;429&lt;/code&gt; if exceeded. It works. And that's exactly the problem: &lt;em&gt;it works for the case that doesn't matter&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hidden cost 1: IP-based granularity is weak where it hurts most.&lt;/strong&gt;&lt;br&gt;
The most sensitive endpoints — login, password recovery, registration — are precisely the ones a resourceful attacker will hit from multiple IPs. IP rate limiting stops amateur abuse. It doesn't stop well-crafted credential stuffing. For that you need to combine IP + User-Agent fingerprinting, or put CAPTCHA directly in the flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hidden cost 2: a 429 without logging is a blind control.&lt;/strong&gt;&lt;br&gt;
If the middleware blocks and you don't emit a structured log with at minimum &lt;code&gt;{ ip, route, timestamp, reason }&lt;/code&gt;, you have no way to tell the difference between a real attack and a legitimate user behind a corporate NAT sharing an IP with a hundred colleagues. That legitimate user gets a 429 and has no idea why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hidden cost 3: a global config flattens the policy.&lt;/strong&gt;&lt;br&gt;
A global limit of 100 requests per minute might be generous for a login endpoint and absurdly restrictive for a public search API. When everything uses the same configuration, the policy has no real semantics — it's an arbitrary number nobody ever justified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concrete counterexample:&lt;/strong&gt; a contact form that fires an email has a real per-request cost (the email send). There, rate limiting by IP + by session makes sense, and the threshold should be low. A public read endpoint serving already-cached static data has a completely different abuse profile. Modeling them the same way is just imprecise.&lt;/p&gt;

&lt;p&gt;Same logic I went into with &lt;a href="https://juanchi.dev/en/blog/nextjs-app-router-caching-revalidate-dynamic-no-store-3" rel="noopener noreferrer"&gt;caching in Next.js App Router&lt;/a&gt;: there's no one config that works for everything. Every route has a behavioral contract.&lt;/p&gt;


&lt;h2&gt;
  
  
  Decision checklist: before you copy the middleware
&lt;/h2&gt;

&lt;p&gt;This checklist doesn't replace per-system analysis. It's a starting point to structure the conversation before you open the library docs.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Define the asset
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route type&lt;/th&gt;
&lt;th&gt;Sensitivity&lt;/th&gt;
&lt;th&gt;Minimum recommended control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Login / authentication&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Rate limit by IP + by user if it exists; consider CAPTCHA on retries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password recovery&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Limit by IP; notify user; don't reveal whether the email exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Form with side effect (email, payment)&lt;/td&gt;
&lt;td&gt;Medium-High&lt;/td&gt;
&lt;td&gt;Rate limit by IP + session; log every block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public read API without auth&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Global rate limit by IP; consider whether caching already mitigates the risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authenticated API&lt;/td&gt;
&lt;td&gt;Low-Medium&lt;/td&gt;
&lt;td&gt;Rate limit by token/user; differentiated policy by plan if applicable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static or health routes&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;No rate limit; false positive cost is maximum&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Prudent criteria based on documented patterns. The numeric threshold depends on each system's real traffic volume.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Define the expected abuse
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Brute force / credential stuffing:&lt;/strong&gt; control at the authentication endpoint, not across the whole app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Form spam:&lt;/strong&gt; rate limit by IP + honeypot + CAPTCHA if the volume justifies it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scraping:&lt;/strong&gt; rate limit by IP + User-Agent headers; consider whether the content should be public anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layer 7 DDoS:&lt;/strong&gt; app-level rate limiting isn't enough; that problem lives at the infrastructure layer (CDN, WAF, Railway regions).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  3. Measure the cost of the false positive
&lt;/h3&gt;

&lt;p&gt;Before setting a threshold, answer: what happens if a legitimate user exceeds it? If the answer is "they lose a session they can recover easily," the threshold can be more aggressive. If the answer is "they lose a purchase or can't get into an active account," the threshold needs more tolerance — or a progressive challenge mechanism instead of a hard block.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Define observability from day one
&lt;/h3&gt;

&lt;p&gt;A minimal snippet of what you should log on every block:&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="c1"&gt;// app/api/login/route.ts — reproducible example, not production code&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// Rate limit check goes BEFORE any business logic&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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;x-forwarded-for&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;limitExceeded&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;checkRateLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// your implementation&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;limitExceeded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ALWAYS log: IP, route, timestamp, reason&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;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rate_limit_exceeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;route&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/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Too many requests&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Authentication logic here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without that log, the middleware is a black box. You don't know if it blocked ten bots or ten real users. I covered the observability angle in the &lt;a href="https://juanchi.dev/en/blog/docker-healthcheck-what-it-measures-decision-matrix" rel="noopener noreferrer"&gt;Docker healthchecks&lt;/a&gt; post too: measuring well is part of the control, not an optional extra.&lt;/p&gt;




&lt;h2&gt;
  
  
  Honest limits: what you can't conclude without your own data
&lt;/h2&gt;

&lt;p&gt;I'm saying this explicitly because a lot of rate limiting content just skips it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You can't know the right threshold without real traffic logs.&lt;/strong&gt; The number 10, 100, or 1000 requests per minute means nothing out of context. If you don't have real usage data, start with a conservative threshold, log it, and adjust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IP-based rate limiting is not equivalent to user-based rate limiting.&lt;/strong&gt; Behind a corporate NAT there can be dozens of users sharing one IP. An aggressive per-IP policy in that context generates systematic false positives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't assume the 429 reached the right attacker.&lt;/strong&gt; Without logs, you don't know who you blocked. With logs, you have evidence to adjust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting doesn't replace authentication, authorization, or input validation.&lt;/strong&gt; It's a last-line control against volumetric abuse, not a general security layer. Same logic applies to &lt;a href="https://juanchi.dev/en/blog/digital-signature-format-certificate-validation-policy-2" rel="noopener noreferrer"&gt;digital signatures&lt;/a&gt;: every control has its domain, none of them covers everything.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  FAQ: common questions about rate limiting in Next.js
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What rate limiting library do you recommend for Next.js?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;@upstash/ratelimit&lt;/code&gt; is the most documented option for Next.js with Redis on the edge. &lt;code&gt;express-rate-limit&lt;/code&gt; works fine if your backend is plain Node.js. But the library is the last step, not the first. If you haven't defined what asset you're protecting and what abuse you're expecting, any library gives you a false sense of control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting in Next.js middleware or in each Route Handler?&lt;/strong&gt;&lt;br&gt;
Depends on the granularity you need. Next.js middleware runs before the request reaches the route — useful for global or prefix-level protection. Route Handlers let you have different policies per endpoint. The most practical approach is usually: base policy in middleware, specific policy on sensitive endpoints (login, password recovery, forms with side effects).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is IP enough as an identifier for rate limiting?&lt;/strong&gt;&lt;br&gt;
For amateur abuse, yes. For distributed attacks, no. If the asset you're protecting is critical — login, payments, endpoints that fire emails — combine IP with session or user identifier when it exists, and consider CAPTCHA as a progressive challenge instead of a hard block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I handle rate limiting on Railway without Redis?&lt;/strong&gt;&lt;br&gt;
Railway lets you add Redis as an internal service. If you don't want that dependency, you can use an in-memory store for prototypes — but you have to accept that the limit won't be shared across instances. In production with multiple replicas, a shared store is necessary for the policy to be consistent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does infrastructure-layer rate limiting (CDN, WAF) replace the code-level one?&lt;/strong&gt;&lt;br&gt;
No, they're complementary. The infrastructure layer protects against raw volume and layer 7 DDoS. Application-level rate limiting enables semantic policies: by user, by action type, by cost of the operation. Both together are more robust than either one alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What do I do if rate limiting is blocking legitimate users?&lt;/strong&gt;&lt;br&gt;
First, log it. Second, check whether the problem is the threshold (too low) or the identifier (IP instead of user). Third, consider a progressive challenge mechanism: instead of blocking outright, ask for confirmation, CAPTCHA, or a short wait. Hard blocking should be the last resort, not the default response.&lt;/p&gt;




&lt;h2&gt;
  
  
  The policy before the dependency
&lt;/h2&gt;

&lt;p&gt;The scene I opened with — an endpoint with zero abuse controls in a reference project — wasn't a missing-library problem. It was a design problem: nobody had defined what to protect, from what, and at what cost.&lt;/p&gt;

&lt;p&gt;That order matters. If you pick the library first and then ask yourself what to configure, you end up with middleware that acts but has no policy. It acts in silence, blocks without documented criteria, and tells you nothing when it fails.&lt;/p&gt;

&lt;p&gt;What I think is honest to recommend:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;List the assets that deserve protection&lt;/strong&gt; — not the whole app, just the endpoints with real side effects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Define the type of abuse&lt;/strong&gt; before choosing the control. They're different problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calculate the false positive cost&lt;/strong&gt; for each asset before setting the threshold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log it from day one.&lt;/strong&gt; Without logs, the control is decoration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the library that fits the above&lt;/strong&gt;, not the other way around.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Well-applied rate limiting is a real defensive control. Badly applied, it's a number in a config file that nobody will revisit until a legitimate user calls asking why they can't get in.&lt;/p&gt;

&lt;p&gt;The question I'll leave you with is concrete: do you know today how many 429s your app returned in the last 24 hours, and to whom?&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Primary source:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OWASP Authentication Cheat Sheet — defensive controls around authentication and abuse: &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html" rel="noopener noreferrer"&gt;https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://juanchi.dev/en/blog/rate-limiting-web-apps-what-to-protect-before-picking-library" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>english</category>
      <category>typescript</category>
      <category>nextjs</category>
      <category>railway</category>
    </item>
    <item>
      <title>Rate limiting en aplicaciones web: qué proteger antes de elegir una librería</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Tue, 07 Jul 2026 12:00:47 +0000</pubDate>
      <link>https://dev.to/jtorchia/rate-limiting-en-aplicaciones-web-que-proteger-antes-de-elegir-una-libreria-443l</link>
      <guid>https://dev.to/jtorchia/rate-limiting-en-aplicaciones-web-que-proteger-antes-de-elegir-una-libreria-443l</guid>
      <description>&lt;h1&gt;
  
  
  Rate limiting en aplicaciones web: qué proteger antes de elegir una librería
&lt;/h1&gt;

&lt;p&gt;Estuve meses convencido de que rate limiting era cuestión de agregar un middleware, configurar un número de requests por minuto y seguir. Me alcanzó con ver un endpoint de login sin ningún control —en un proyecto de referencia que usé para estudiar arquitectura— para entender que el problema no era la librería que faltaba. Era que nadie había definido qué se estaba protegiendo, de qué tipo de abuso y a qué costo.&lt;/p&gt;

&lt;p&gt;No lo cuento para quedar mal, lo cuento porque probablemente vos también vas a copiar ese snippet de &lt;code&gt;upstash/ratelimit&lt;/code&gt; o &lt;code&gt;express-rate-limit&lt;/code&gt; antes de hacerte esa pregunta. Y si lo hacés, vas a tener rate limiting que funciona en el papel y falla exactamente donde más importa.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mi tesis:&lt;/strong&gt; rate limiting no es una dependencia. Es una política de abuso. Y una política sin contexto es decoración.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rate limiting en aplicaciones web con Next.js: el problema real
&lt;/h2&gt;

&lt;p&gt;La mayoría de los tutoriales sobre rate limiting en Next.js arrancan desde la librería. Te muestran cómo configurar &lt;code&gt;Upstash Redis&lt;/code&gt;, cuántos tokens poner en el bucket y cómo devolver un &lt;code&gt;429&lt;/code&gt;. Todo eso está bien. El problema es que esa secuencia saltea cuatro preguntas que deberían ir primero:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;¿Qué activo estás protegiendo?&lt;/strong&gt; ¿Un endpoint de login? ¿Una API pública sin autenticación? ¿Un formulario de contacto? ¿Una ruta que dispara un email? No todos merecen la misma política.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;¿Qué tipo de abuso esperás?&lt;/strong&gt; Credential stuffing, scraping, spam de formulario y DDoS son cuatro problemas distintos. Rate limiting resuelve algunos, no todos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;¿Cuánto te cuesta un falso positivo?&lt;/strong&gt; Bloquear a un usuario legítimo en el checkout de un e-commerce tiene un costo diferente al de bloquear un bot en una API de consulta pública.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;¿Cómo vas a saber que está funcionando?&lt;/strong&gt; Sin logs, sin métricas, sin alertas, el middleware actúa en silencio y no sabés si está bloqueando lo que tiene que bloquear o lo que no debería.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Estos cuatro puntos no son filosóficos. Son la diferencia entre un control que protege y uno que da una sensación falsa de seguridad.&lt;/p&gt;




&lt;h2&gt;
  
  
  Qué dice OWASP y qué no dice
&lt;/h2&gt;

&lt;p&gt;La &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Authentication Cheat Sheet&lt;/a&gt; es la referencia pública más citada cuando se habla de controles defensivos en autenticación y abuso. Vale la pena leerla directo en lugar de confiar en el resumen de alguien más.&lt;/p&gt;

&lt;p&gt;Lo que OWASP dice explícitamente en ese documento:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Implementar controles de límite de intentos fallidos de login para mitigar ataques de fuerza bruta.&lt;/li&gt;
&lt;li&gt;Considerar lockout temporal, CAPTCHA o notificación al usuario como respuestas posibles.&lt;/li&gt;
&lt;li&gt;El lockout agresivo puede usarse como vector de denegación de servicio contra usuarios legítimos (es decir, el falso positivo tiene costo de seguridad, no solo de UX).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lo que OWASP &lt;strong&gt;no&lt;/strong&gt; dice en ese documento:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cuántas requests por minuto es el número correcto. No existe ese número fuera de contexto.&lt;/li&gt;
&lt;li&gt;Que rate limiting por IP sea suficiente. Un atacante con infraestructura distribuida rota IPs trivialmente.&lt;/li&gt;
&lt;li&gt;Que todas las rutas de una aplicación merezcan el mismo control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Esto importa porque mucha gente cita "OWASP recomienda rate limiting" como si fuera una receta universal. No lo es. Es un control entre varios, con precondiciones y trade-offs explícitos.&lt;/p&gt;

&lt;p&gt;Si ya leíste mi post sobre &lt;a href="https://juanchi.dev/es/blog/spring-boot-actuator-endpoints-seguridad-3" rel="noopener noreferrer"&gt;qué exponer y qué ocultar en Spring Boot Actuator&lt;/a&gt;, reconocés el patrón: el problema no es activar o desactivar una feature. Es entender qué decisión tomás y por qué.&lt;/p&gt;




&lt;h2&gt;
  
  
  Dónde se equivoca la receta común
&lt;/h2&gt;

&lt;p&gt;La receta típica en Next.js App Router se ve así: instalás &lt;code&gt;@upstash/ratelimit&lt;/code&gt;, wrapeás el handler de la Route Handler o el Server Action, configurás 10 requests por minuto por IP y devolvés &lt;code&gt;429&lt;/code&gt; si se supera. Funciona. Y acá está el problema: &lt;em&gt;funciona para el caso que no importa&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Costo oculto 1: la granularidad por IP es débil donde más duele.&lt;/strong&gt;&lt;br&gt;
Los endpoints más sensibles —login, recuperación de contraseña, registro— son exactamente los que un atacante con recursos va a atacar desde múltiples IPs. Rate limiting por IP frena el abuso amateur. No frena credential stuffing bien armado. Para eso necesitás combinar IP + User-Agent fingerprinting, o directamente CAPTCHA en el flujo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Costo oculto 2: el 429 sin logging es un control ciego.&lt;/strong&gt;&lt;br&gt;
Si el middleware bloquea y no emitís un log estructurado con al minimum &lt;code&gt;{ ip, route, timestamp, reason }&lt;/code&gt;, no tenés forma de distinguir un ataque real de un usuario detrás de un NAT corporativo que comparte IP con cien colegas. Ese usuario legítimo va a recibir un 429 y no va a entender por qué.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Costo oculto 3: la configuración global aplana la política.&lt;/strong&gt;&lt;br&gt;
Un límite global de 100 requests por minuto puede ser generoso para un endpoint de login y absurdamente restrictivo para una API de búsqueda pública. Cuando todo usa la misma configuración, la política no tiene semántica real: es un número arbitrario que nadie justificó.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contraejemplo concreto:&lt;/strong&gt; un formulario de contacto que dispara un email tiene un costo real por request (el envío del email). Ahí rate limiting por IP + por sesión tiene sentido, y el umbral debería ser bajo. Un endpoint de lectura pública que sirve datos estáticos ya cacheados tiene un perfil de abuso completamente diferente. Modelarlos igual es impreciso.&lt;/p&gt;

&lt;p&gt;En el mismo sentido en que hablé de &lt;a href="https://juanchi.dev/es/blog/nextjs-app-router-caching-revalidate-dynamic-no-store-3" rel="noopener noreferrer"&gt;caching en Next.js App Router&lt;/a&gt;: no existe una configuración que sirva para todo. Cada ruta tiene un contrato de comportamiento.&lt;/p&gt;


&lt;h2&gt;
  
  
  Checklist de decisión: antes de copiar el middleware
&lt;/h2&gt;

&lt;p&gt;Este checklist no reemplaza el análisis de cada sistema. Es un punto de partida para estructurar la conversación antes de abrir la documentación de la librería.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Definí el activo
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tipo de ruta&lt;/th&gt;
&lt;th&gt;Sensibilidad&lt;/th&gt;
&lt;th&gt;Control mínimo recomendado&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Login / autenticación&lt;/td&gt;
&lt;td&gt;Alta&lt;/td&gt;
&lt;td&gt;Rate limit por IP + por usuario si existe; considerar CAPTCHA en reintentos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recuperación de contraseña&lt;/td&gt;
&lt;td&gt;Alta&lt;/td&gt;
&lt;td&gt;Limit por IP; notificación al usuario; no revelar si el email existe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formulario con efecto colateral (email, pago)&lt;/td&gt;
&lt;td&gt;Media-Alta&lt;/td&gt;
&lt;td&gt;Rate limit por IP + sesión; log de cada bloqueo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API pública de lectura sin auth&lt;/td&gt;
&lt;td&gt;Media&lt;/td&gt;
&lt;td&gt;Rate limit global por IP; considerar si el caching ya mitiga el riesgo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API autenticada&lt;/td&gt;
&lt;td&gt;Baja-Media&lt;/td&gt;
&lt;td&gt;Rate limit por token/usuario; política diferenciada por plan si aplica&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rutas estáticas o de health&lt;/td&gt;
&lt;td&gt;No aplica&lt;/td&gt;
&lt;td&gt;Sin rate limit; el costo de falso positivo es máximo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Criterios prudentes basados en patrones documentados. El umbral numérico depende del volumen real de cada sistema.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Definí el abuso esperado
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fuerza bruta / credential stuffing:&lt;/strong&gt; control en el endpoint de autenticación, no en toda la app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spam de formulario:&lt;/strong&gt; rate limit por IP + honeypot + CAPTCHA si el volumen lo justifica.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scraping:&lt;/strong&gt; rate limit por IP + headers de User-Agent; considerar si el contenido debería ser público de todas formas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DDoS de capa 7:&lt;/strong&gt; rate limiting en la app no es suficiente; ese problema vive en la capa de infraestructura (CDN, WAF, Railway regions).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  3. Medí el costo del falso positivo
&lt;/h3&gt;

&lt;p&gt;Antes de configurar un umbral, contestá: ¿qué pasa si un usuario legítimo lo supera? Si la respuesta es "pierde una sesión que puede recuperar fácil", el umbral puede ser más agresivo. Si la respuesta es "pierde una compra o no puede entrar a una cuenta activa", el umbral necesita más tolerancia o un mecanismo de desafío progresivo en lugar de bloqueo directo.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Definí observabilidad desde el día uno
&lt;/h3&gt;

&lt;p&gt;Un snippet mínimo de lo que deberías loguear en cada bloqueo:&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="c1"&gt;// app/api/login/route.ts — ejemplo reproducible, no código de producción&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// El chequeo de rate limit va ANTES de cualquier lógica de negocio&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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;x-forwarded-for&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;limitExceeded&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;checkRateLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// vos implementación&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;limitExceeded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Loguear SIEMPRE: IP, ruta, timestamp, motivo&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;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rate_limit_exceeded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;route&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/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Too many requests&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Lógica de autenticación acá&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sin ese log, el middleware es una caja negra. No sabés si bloqueó diez bots o diez usuarios reales. El tema de observabilidad lo traté también en el post de &lt;a href="https://juanchi.dev/es/blog/docker-healthcheck-buenas-practicas-que-miden-y-que-no" rel="noopener noreferrer"&gt;Docker healthchecks&lt;/a&gt;: medir bien es parte del control, no un extra opcional.&lt;/p&gt;




&lt;h2&gt;
  
  
  Límites honestos: qué no podés concluir sin datos propios
&lt;/h2&gt;

&lt;p&gt;Esto lo digo explícitamente porque mucho contenido sobre rate limiting lo omite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No podés saber el umbral correcto sin logs reales de tráfico.&lt;/strong&gt; El número 10, 100 o 1000 requests por minuto no tiene sentido fuera de contexto. Si no tenés datos de uso real, empezá con un umbral conservador, loguéalo y ajustá.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting por IP no es equivalente a rate limiting por usuario.&lt;/strong&gt; Detrás de un NAT corporativo puede haber decenas de usuarios en la misma IP. Una política agresiva por IP en ese contexto genera falsos positivos sistemáticos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No podés asumir que el 429 llegó al atacante correcto.&lt;/strong&gt; Sin logs, no sabés a quién bloqueaste. Con logs, tenés evidencia para ajustar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting no reemplaza autenticación, autorización ni validación de inputs.&lt;/strong&gt; Es un control de última línea frente a abuso volumétrico, no una capa de seguridad general. La misma lógica aplica a &lt;a href="https://juanchi.dev/es/blog/firma-digital-formato-certificado-politica-validacion-3" rel="noopener noreferrer"&gt;firma digital&lt;/a&gt;: cada control tiene su dominio, no hay uno que cubra todo.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  FAQ: preguntas frecuentes sobre rate limiting en Next.js
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;¿Qué librería de rate limiting recomendás para Next.js?&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;@upstash/ratelimit&lt;/code&gt; es la opción más documentada para Next.js con Redis en el edge. &lt;code&gt;express-rate-limit&lt;/code&gt; funciona si el backend es Node.js puro. Pero la librería es el último paso, no el primero. Si no definiste qué activo protegés y qué abuso esperás, cualquier librería te da una falsa sensación de control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Rate limiting en middleware de Next.js o en cada Route Handler?&lt;/strong&gt;&lt;br&gt;
Depende de la granularidad que necesitás. El middleware de Next.js corre antes de que la request llegue a la ruta: es útil para protección global o por prefijo. Los Route Handlers permiten políticas distintas por endpoint. Lo más práctico suele ser: política base en middleware, política específica en los endpoints sensibles (login, recuperación de contraseña, formularios con efectos).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿IP es suficiente como identificador para rate limiting?&lt;/strong&gt;&lt;br&gt;
Para abuso amateur, sí. Para ataques distribuidos, no. Si el activo que protegés es crítico —login, pagos, endpoints que disparan emails—, combiná IP con identificador de sesión o usuario cuando exista, y considerá CAPTCHA como desafío progresivo en lugar de bloqueo directo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Cómo manejo rate limiting en Railway sin Redis?&lt;/strong&gt;&lt;br&gt;
Railway permite agregar Redis como servicio interno. Si no querés esa dependencia, podés usar un store en memoria para prototipos —pero tenés que aceptar que el límite no se comparte entre instancias. En producción con múltiples réplicas, un store compartido es necesario para que la política sea consistente.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿El rate limiting de la capa de infraestructura (CDN, WAF) reemplaza el del código?&lt;/strong&gt;&lt;br&gt;
No, son complementarios. La capa de infraestructura protege contra volumen bruto y DDoS de capa 7. El rate limiting en la aplicación permite políticas semánticas: por usuario, por tipo de acción, por costo de la operación. Los dos juntos son más robustos que cualquiera de los dos solos.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Qué hago si el rate limiting bloquea usuarios legítimos?&lt;/strong&gt;&lt;br&gt;
Primero, loguéalo. Segundo, revisá si el problema es el umbral (muy bajo) o el identificador (IP en lugar de usuario). Tercero, considerá un mecanismo de desafío progresivo: en lugar de bloquear directo, pedí confirmación, CAPTCHA o una espera corta. El bloqueo duro debería ser el último recurso, no la respuesta por defecto.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusión: la política antes que la dependencia
&lt;/h2&gt;

&lt;p&gt;La escena con la que arranqué —un endpoint sin ningún control de abuso en un proyecto de referencia— no era un problema de librería faltante. Era un problema de diseño: nadie había definido qué proteger, de qué y a qué costo.&lt;/p&gt;

&lt;p&gt;Ese orden importa. Si primero elegís la librería y después te preguntás qué configurar, terminás con un middleware que actúa pero no tiene política. Actúa en silencio, bloquea sin criterio documentado y no te dice nada cuando falla.&lt;/p&gt;

&lt;p&gt;Lo que me parece honesto recomendar:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Listá los activos que merecen protección&lt;/strong&gt; —no toda la app, los endpoints con efectos colaterales reales.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Definí el tipo de abuso&lt;/strong&gt; antes de elegir el control. Son problemas distintos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calculá el costo del falso positivo&lt;/strong&gt; para cada activo antes de configurar el umbral.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loguéalo desde el día uno.&lt;/strong&gt; Sin logs, el control es decoración.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usá la librería que encaje con lo anterior&lt;/strong&gt;, no al revés.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Rate limiting bien aplicado es un control defensivo real. Mal aplicado es un número en una configuración que nadie va a revisar hasta que un usuario legítimo llame por teléfono preguntando por qué no puede entrar.&lt;/p&gt;

&lt;p&gt;La pregunta que te dejo es concreta: ¿sabés hoy cuántos 429 devolvió la app en las últimas 24 horas y a quién?&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Fuente original:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OWASP Authentication Cheat Sheet — controles defensivos alrededor de autenticación y abuso: &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html" rel="noopener noreferrer"&gt;https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Este artículo fue publicado originalmente en &lt;a href="https://juanchi.dev/es/blog/rate-limiting-aplicaciones-web-nextjs-2" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>espanol</category>
      <category>typescript</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Spring Boot Actuator: What to Expose, What to Hide, and What to Check Before Adding Endpoints</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Mon, 06 Jul 2026 12:01:36 +0000</pubDate>
      <link>https://dev.to/jtorchia/spring-boot-actuator-what-to-expose-what-to-hide-and-what-to-check-before-adding-endpoints-1kdi</link>
      <guid>https://dev.to/jtorchia/spring-boot-actuator-what-to-expose-what-to-hide-and-what-to-check-before-adding-endpoints-1kdi</guid>
      <description>&lt;h1&gt;
  
  
  Spring Boot Actuator: What to Expose, What to Hide, and What to Check Before Adding Endpoints
&lt;/h1&gt;

&lt;p&gt;There's a configuration that shows up in almost every Spring Boot tutorial, and people copy it without really reading it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;exposure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One asterisk. Every endpoint. All at once. And the tutorial moves straight to the next step without pausing for even a second to think about what that actually means.&lt;/p&gt;

&lt;p&gt;My take is simple: &lt;strong&gt;Actuator isn't bad. The mistake is enabling it without a clear policy for what to expose, what to restrict, and for whom.&lt;/strong&gt; That gap between operational tool and unnecessary public surface area isn't something Spring Boot closes on its own — you close it with explicit judgment.&lt;/p&gt;

&lt;p&gt;I've been in tech for over thirty years, and one of the patterns I keep seeing isn't the dramatic bug that takes down production: it's the small decision made without a policy, accumulating quietly until someone finds it from the outside.&lt;/p&gt;




&lt;h2&gt;
  
  
  Spring Boot Actuator Endpoints Security: What the Docs Say — and What They Don't
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://docs.spring.io/spring-boot/reference/actuator/endpoints.html" rel="noopener noreferrer"&gt;official Spring Boot Actuator documentation&lt;/a&gt; is clear about something most tutorials skip: &lt;strong&gt;by default, only &lt;code&gt;health&lt;/code&gt; and &lt;code&gt;info&lt;/code&gt; are exposed over HTTP&lt;/strong&gt;. The rest exist but aren't automatically accessible.&lt;/p&gt;

&lt;p&gt;What the documentation spells out precisely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The distinction between &lt;strong&gt;enabling&lt;/strong&gt; an endpoint (it exists in the context) and &lt;strong&gt;exposing&lt;/strong&gt; it (it's reachable via HTTP or JMX).&lt;/li&gt;
&lt;li&gt;That &lt;code&gt;shutdown&lt;/code&gt; is enabled but &lt;strong&gt;not exposed&lt;/strong&gt; by default — precisely because its impact is irreversible.&lt;/li&gt;
&lt;li&gt;That &lt;code&gt;env&lt;/code&gt; and &lt;code&gt;configprops&lt;/code&gt; can leak sensitive values, and that Spring Boot offers configurable sanitization for those cases.&lt;/li&gt;
&lt;li&gt;That securing endpoints is the developer's responsibility: Spring Boot doesn't add automatic authentication over Actuator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the documentation &lt;strong&gt;doesn't resolve for you&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which endpoints actually make sense for your specific operation.&lt;/li&gt;
&lt;li&gt;Whether the management port is separated from the app port or shared.&lt;/li&gt;
&lt;li&gt;Whether there's a proxy, API gateway, or load balancer in front that filters — or doesn't filter — those routes.&lt;/li&gt;
&lt;li&gt;Whether the team operating the system has differentiated access from public traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gap between "what Spring Boot configures by default" and "what you need in production" is exactly where implicit decisions pile up.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Most Common Mistake: Enable Everything and Secure It Later (or Never)
&lt;/h2&gt;

&lt;p&gt;The recipe that circulates across tutorials, Stack Overflow, and example repos usually looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# application.yml — common pattern worth reviewing before copying&lt;/span&gt;
&lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;exposure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;health&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;show-details&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hidden cost of this pattern isn't that it's "dangerous" in the abstract. The concrete cost is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/actuator/env&lt;/code&gt;&lt;/strong&gt; exposes environment variables — including keys, database URLs, and tokens if they aren't sanitized.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/actuator/heapdump&lt;/code&gt;&lt;/strong&gt; can return a full JVM heap dump, which in many systems contains live in-memory data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/actuator/shutdown&lt;/code&gt;&lt;/strong&gt; lets you kill the application via HTTP POST if it's enabled and exposed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/actuator/loggers&lt;/code&gt;&lt;/strong&gt; lets you change log levels at runtime, which can generate operational noise or expose diagnostic information.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of those endpoints are inherently bad. &lt;code&gt;heapdump&lt;/code&gt; is invaluable for diagnosing a memory leak. &lt;code&gt;loggers&lt;/code&gt; is exactly the tool you want when you need DEBUG output without a restart. The problem is exposing them without deciding who can reach them and from where.&lt;/p&gt;

&lt;p&gt;The analogy I keep coming back to: it's similar to what happens with &lt;a href="https://juanchi.dev/en/blog/docker-healthcheck-what-it-measures-decision-matrix" rel="noopener noreferrer"&gt;Docker healthchecks&lt;/a&gt;. A badly designed healthcheck doesn't fail because Docker is broken — it fails because nobody defined what "healthy" actually means for that container. Actuator is the same story: the tool is solid, but the exposure criteria has to be explicit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision Matrix: What to Expose, What to Restrict, What to Disable Entirely
&lt;/h2&gt;

&lt;p&gt;This matrix is a pragmatic guide, not an absolute rule. Each row represents an analysis worth doing before including that endpoint.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;What it exposes&lt;/th&gt;
&lt;th&gt;Recommended approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;health&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;App state and dependency health&lt;/td&gt;
&lt;td&gt;Expose. Use &lt;code&gt;show-details: when-authorized&lt;/code&gt; in production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;info&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Build metadata, version, git commit&lt;/td&gt;
&lt;td&gt;Expose if you have useful data. Nothing sensitive by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;metrics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JVM, HTTP, and custom metrics&lt;/td&gt;
&lt;td&gt;Expose only to internal monitoring systems (Prometheus, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;loggers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current logging levels&lt;/td&gt;
&lt;td&gt;Restrict to operators. Useful, but with controlled access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Environment variables&lt;/td&gt;
&lt;td&gt;Restrict. Review sanitization before enabling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;configprops&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Configuration properties&lt;/td&gt;
&lt;td&gt;Restrict. Can surface sensitive values&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heapdump&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JVM heap dump&lt;/td&gt;
&lt;td&gt;Controlled environments only. Not on public production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;threaddump&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Active thread state&lt;/td&gt;
&lt;td&gt;Useful for diagnostics. Restrict to operators&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shutdown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shuts down the application&lt;/td&gt;
&lt;td&gt;Don't enable unless there's a very clear operational reason&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mappings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All REST endpoints in the app&lt;/td&gt;
&lt;td&gt;Surface area information. Restrict&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;beans&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All Spring context beans&lt;/td&gt;
&lt;td&gt;Development only. Not in production&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The configuration I'd use as a starting point in a production environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# application.yml — pragmatic starting point for production&lt;/span&gt;
&lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8081&lt;/span&gt;  &lt;span class="c1"&gt;# separate port, not publicly exposed&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;exposure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;health, info, metrics, loggers&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;health&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;show-details&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;when-authorized&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;# disable if no sanitization policy is defined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Separating the management port (&lt;code&gt;management.server.port&lt;/code&gt;) from the application port is a decision that dramatically simplifies your policy: internal traffic hits 8081, public traffic has no access to that port at all. It turns security into a structural property instead of something that depends entirely on getting every auth rule right in code.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Can't Conclude Without Your Own Experiments, Logs, or Data
&lt;/h2&gt;

&lt;p&gt;There's an honest limit worth naming here. This guide is based on the official documentation and careful analysis. What &lt;strong&gt;you can't conclude from this alone&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That your current configuration is secure: that requires checking what's in front of your app (proxy, gateway, firewall) and how it's configured.&lt;/li&gt;
&lt;li&gt;That separating the management port is sufficient: if port 8081 is open in your security group or network firewall, the separation means nothing.&lt;/li&gt;
&lt;li&gt;That &lt;code&gt;env&lt;/code&gt; sanitization covers all your sensitive keys: Spring Boot sanitizes by known patterns (&lt;code&gt;password&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;key&lt;/code&gt;, etc.), but if you're using unconventional variable names, verify it explicitly.&lt;/li&gt;
&lt;li&gt;That adding Spring Security over Actuator is the only solution: you can also handle access at the network level, proxy level, or ingress level. The right layer depends on your architecture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to inspect what traffic is actually reaching your management endpoints in a local environment, a tool like &lt;a href="https://juanchi.dev/en/blog/sniffnet-monitor-network-traffic-without-tcpdump" rel="noopener noreferrer"&gt;Sniffnet&lt;/a&gt; can help you visualize connections without diving straight into tcpdump.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: Spring Boot Actuator Endpoints Security
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Which Actuator endpoints are enabled by default?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;According to the &lt;a href="https://docs.spring.io/spring-boot/reference/actuator/endpoints.html" rel="noopener noreferrer"&gt;official documentation&lt;/a&gt;, only &lt;code&gt;health&lt;/code&gt; and &lt;code&gt;info&lt;/code&gt; are exposed over HTTP by default. The rest are enabled in the context but not reachable via HTTP. The exception is &lt;code&gt;shutdown&lt;/code&gt;, which isn't enabled by default precisely because of its impact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between enabling and exposing an endpoint?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Enabling means the endpoint exists in the Spring context and can be invoked internally. Exposing means it's reachable from outside (HTTP or JMX). You can have an endpoint enabled but not exposed: it exists, but nothing can call it from the outside. The &lt;code&gt;management.endpoints.web.exposure.include&lt;/code&gt; property controls HTTP exposure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Spring Security enough to protect Actuator?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Spring Security is a valid layer, but it's not the only one — and it's not necessarily your first line of defense. Separating the management port and restricting access to it at the network or proxy level is a more robust policy because it doesn't depend on every security rule in your code being correct. Ideally, both layers coexist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;/actuator/health&lt;/code&gt; expose sensitive information?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With the default configuration, &lt;code&gt;health&lt;/code&gt; returns only the status (&lt;code&gt;UP&lt;/code&gt; or &lt;code&gt;DOWN&lt;/code&gt;). Dependency details (database, disk, etc.) only appear if you configure &lt;code&gt;show-details: always&lt;/code&gt;. The more sensible middle ground for production is &lt;code&gt;show-details: when-authorized&lt;/code&gt;: your monitoring system sees the detail, anonymous traffic doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if I use &lt;code&gt;include: "*"&lt;/code&gt; in production?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every enabled endpoint gets exposed — including &lt;code&gt;env&lt;/code&gt;, &lt;code&gt;configprops&lt;/code&gt;, &lt;code&gt;heapdump&lt;/code&gt;, and potentially &lt;code&gt;shutdown&lt;/code&gt; if you've enabled it. It's not an invalid configuration in every context, but it requires that access to the management port be completely restricted to internal systems. If that port is publicly reachable, anyone can query your environment variables or request a heap dump.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I validate which endpoints are actually exposed in my app?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/actuator&lt;/code&gt; endpoint returns a JSON with active endpoints and their URLs. That's your first stop. You can also check Spring Boot's startup logs: it logs registered endpoints at the right log level. And if you have Prometheus or any scraping system configured, verify it's only hitting the management port — not the public one.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Take: Actuator Is an Operational Tool, Not a Convenience Feature
&lt;/h2&gt;

&lt;p&gt;When I first encountered the Docker healthcheck story I mentioned in that earlier post, what stuck with me was the same lesson that applies here: the value of a tool is in describing an explicit contract, not in turning it on and seeing what happens.&lt;/p&gt;

&lt;p&gt;Actuator is the same. It's not a feature you "add" to the app. It's an observability policy that defines what operational information gets exposed, to whom, and from where. That policy can be as simple as two lines in your YAML or as elaborate as differentiated roles with Spring Security — but it has to exist consciously.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is that the tutorial everyone copies shows &lt;code&gt;include: "*"&lt;/code&gt; as if it were the natural next step. And technically it works. But "it works" and "it's a decision made with judgment" are different things, and that difference matters when the system grows.&lt;/p&gt;

&lt;p&gt;The practical move for right now: open the &lt;code&gt;application.yml&lt;/code&gt; of whatever project you're working on, find the &lt;code&gt;management.endpoints&lt;/code&gt; section, and ask yourself whether what's there is an explicit policy or the result of copying a tutorial without questioning it. If it's the second thing, spend fifteen minutes on it before the system goes to a shared environment.&lt;/p&gt;

&lt;p&gt;Not because Actuator is dangerous. But because implicit decisions are the ones that accumulate.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.spring.io/spring-boot/reference/actuator/endpoints.html" rel="noopener noreferrer"&gt;Spring Boot Actuator — Endpoints (official documentation)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://juanchi.dev/en/blog/spring-boot-actuator-endpoints-security-expose-hide" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>english</category>
      <category>devops</category>
      <category>backend</category>
      <category>produccion</category>
    </item>
    <item>
      <title>Spring Boot Actuator: qué exponer, qué ocultar y qué mirar antes de agregar endpoints</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Mon, 06 Jul 2026 12:01:30 +0000</pubDate>
      <link>https://dev.to/jtorchia/spring-boot-actuator-que-exponer-que-ocultar-y-que-mirar-antes-de-agregar-endpoints-1kgh</link>
      <guid>https://dev.to/jtorchia/spring-boot-actuator-que-exponer-que-ocultar-y-que-mirar-antes-de-agregar-endpoints-1kgh</guid>
      <description>&lt;h1&gt;
  
  
  Spring Boot Actuator: qué exponer, qué ocultar y qué mirar antes de agregar endpoints
&lt;/h1&gt;

&lt;p&gt;Hay una configuración que aparece en casi todos los tutoriales de Spring Boot y que se copia sin leerla bien:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;exposure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un asterisco. Todos los endpoints. De una. Y el tutorial sigue con el paso siguiente sin detenerse un segundo en lo que eso significa.&lt;/p&gt;

&lt;p&gt;Mi tesis es simple: &lt;strong&gt;Actuator no es malo. El error es habilitarlo sin una política clara de qué exponer, qué restringir y para quién.&lt;/strong&gt; Esa diferencia entre herramienta operativa y superficie pública innecesaria no la resuelve Spring Boot solo: la resolvés vos con criterio explícito.&lt;/p&gt;

&lt;p&gt;Tengo más de treinta años de historia con la tecnología, y uno de los patrones que más veo repetirse no es el bug dramático que tira producción: es la decisión pequeña tomada sin política, que se acumula hasta que alguien la encuentra desde afuera.&lt;/p&gt;




&lt;h2&gt;
  
  
  Spring Boot Actuator endpoints seguridad: qué dice la documentación y qué no dice
&lt;/h2&gt;

&lt;p&gt;La &lt;a href="https://docs.spring.io/spring-boot/reference/actuator/endpoints.html" rel="noopener noreferrer"&gt;documentación oficial de Spring Boot Actuator&lt;/a&gt; es clara en algo que los tutoriales suelen saltear: &lt;strong&gt;por defecto, sobre HTTP solo están habilitados &lt;code&gt;health&lt;/code&gt; e &lt;code&gt;info&lt;/code&gt;&lt;/strong&gt;. El resto existe pero no se expone automáticamente.&lt;/p&gt;

&lt;p&gt;Lo que la documentación describe con precisión:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;La distinción entre &lt;strong&gt;habilitar&lt;/strong&gt; un endpoint (que exista en el contexto) y &lt;strong&gt;exponerlo&lt;/strong&gt; (que sea accesible por HTTP o JMX).&lt;/li&gt;
&lt;li&gt;Que &lt;code&gt;shutdown&lt;/code&gt; está habilitado pero &lt;strong&gt;no expuesto&lt;/strong&gt; por defecto, precisamente porque su impacto es irreversible.&lt;/li&gt;
&lt;li&gt;Que &lt;code&gt;env&lt;/code&gt; y &lt;code&gt;configprops&lt;/code&gt; pueden mostrar valores sensibles, y que Spring Boot ofrece sanitización configurable para esos casos.&lt;/li&gt;
&lt;li&gt;Que la seguridad de los endpoints es responsabilidad del desarrollador: Spring Boot no agrega autenticación automática sobre Actuator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lo que la documentación &lt;strong&gt;no resuelve por vos&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Qué endpoints tienen sentido para vos operación concreta.&lt;/li&gt;
&lt;li&gt;Si el puerto de management está separado del puerto de la app o compartido.&lt;/li&gt;
&lt;li&gt;Si hay un proxy, API gateway o balanceador de carga delante que filtra o no esas rutas.&lt;/li&gt;
&lt;li&gt;Si el equipo que opera el sistema tiene acceso diferenciado del tráfico público.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Esa brecha entre "lo que Spring Boot configura por defecto" y "lo que necesitás en producción" es exactamente donde se acumulan las decisiones implícitas.&lt;/p&gt;




&lt;h2&gt;
  
  
  El error más común: habilitar todo y securizar después (o nunca)
&lt;/h2&gt;

&lt;p&gt;La receta que circula en tutoriales, Stack Overflow y repositorios de ejemplo suele tener esta forma:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# application.yml — patrón común que vale revisar antes de copiar&lt;/span&gt;
&lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;exposure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;health&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;show-details&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El costo oculto de este patrón no es que "sea peligroso" en abstracto. El costo concreto es que:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/actuator/env&lt;/code&gt;&lt;/strong&gt; expone variables de entorno, incluyendo claves, URLs de base de datos y tokens si no están sanitizadas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/actuator/heapdump&lt;/code&gt;&lt;/strong&gt; puede devolver un dump del heap de la JVM, que en muchos sistemas contiene datos en memoria.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/actuator/shutdown&lt;/code&gt;&lt;/strong&gt; permite apagar la aplicación vía HTTP POST si está habilitado y expuesto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/actuator/loggers&lt;/code&gt;&lt;/strong&gt; permite cambiar el nivel de logging en caliente, lo que puede generar ruido operativo o exponer información de diagnóstico.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ninguno de esos endpoints es malo en sí mismo. &lt;code&gt;heapdump&lt;/code&gt; es invaluable para diagnosticar un leak de memoria. &lt;code&gt;loggers&lt;/code&gt; es exactamente la herramienta que querés cuando necesitás DEBUG sin reiniciar. El problema es exponerlos sin decidir quién puede acceder y desde dónde.&lt;/p&gt;

&lt;p&gt;La comparación que me resulta útil: es parecido a lo que pasa con los &lt;a href="https://juanchi.dev/es/blog/docker-healthcheck-buenas-practicas-que-miden-y-que-no" rel="noopener noreferrer"&gt;Docker healthchecks&lt;/a&gt;. Un healthcheck mal diseñado no falla porque Docker sea malo; falla porque nadie definió qué significa "sano" para ese contenedor en particular. Con Actuator pasa lo mismo: la herramienta es sólida, pero el criterio de exposición tiene que ser explícito.&lt;/p&gt;




&lt;h2&gt;
  
  
  Matriz de decisión: qué exponer, qué restringir, qué directamente no habilitar
&lt;/h2&gt;

&lt;p&gt;Esta matriz es un criterio prudente, no una regla absoluta. Cada fila representa un análisis que vale hacer antes de incluir ese endpoint.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;¿Qué expone?&lt;/th&gt;
&lt;th&gt;Criterio recomendado&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;health&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Estado de la app y sus dependencias&lt;/td&gt;
&lt;td&gt;Exponer. &lt;code&gt;show-details: when-authorized&lt;/code&gt; en producción&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;info&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Metadata de build, versión, git commit&lt;/td&gt;
&lt;td&gt;Exponer si hay datos útiles. No expone nada sensible por defecto&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;metrics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Métricas JVM, HTTP, custom&lt;/td&gt;
&lt;td&gt;Exponer solo a sistemas de monitoreo internos (Prometheus, etc.)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;loggers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Niveles de logging actuales&lt;/td&gt;
&lt;td&gt;Restringir a operadores. Útil, pero con acceso controlado&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Variables de entorno&lt;/td&gt;
&lt;td&gt;Restringir. Revisar sanitización antes de habilitar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;configprops&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Propiedades de configuración&lt;/td&gt;
&lt;td&gt;Restringir. Puede mostrar valores sensibles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heapdump&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dump del heap de la JVM&lt;/td&gt;
&lt;td&gt;Solo en ambientes controlados. No en producción pública&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;threaddump&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Estado de los threads activos&lt;/td&gt;
&lt;td&gt;Útil para diagnóstico. Restringir a operadores&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shutdown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Apaga la aplicación&lt;/td&gt;
&lt;td&gt;No habilitar salvo que haya una razón operativa muy clara&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mappings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Todos los endpoints REST de la app&lt;/td&gt;
&lt;td&gt;Información de superficie. Restringir&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;beans&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Todos los beans del contexto Spring&lt;/td&gt;
&lt;td&gt;Solo en desarrollo. No en producción&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;La configuración que yo usaría como punto de partida en un ambiente productivo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# application.yml — punto de partida prudente para producción&lt;/span&gt;
&lt;span class="na"&gt;management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8081&lt;/span&gt;  &lt;span class="c1"&gt;# puerto separado, no expuesto públicamente&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;exposure&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;health, info, metrics, loggers&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;health&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;show-details&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;when-authorized&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;  &lt;span class="c1"&gt;# deshabilitar si no hay política de sanitización definida&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Separar el puerto de management (&lt;code&gt;management.server.port&lt;/code&gt;) del puerto de la aplicación es una decisión que simplifica mucho la política: el tráfico interno llega al 8081, el tráfico público no tiene acceso a ese puerto en absoluto. Es una forma de convertir la seguridad en una propiedad estructural en lugar de depender solo de autenticación.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lo que no podés concluir sin experimento, logs o datos propios
&lt;/h2&gt;

&lt;p&gt;Hay un límite honesto que vale nombrar. Esta guía está basada en la documentación oficial y en análisis prudente. Lo que &lt;strong&gt;no podés concluir solo con esto&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Que vos configuración actual es segura: eso requiere revisar qué hay delante de la aplicación (proxy, gateway, firewall) y cómo está configurado.&lt;/li&gt;
&lt;li&gt;Que separar el puerto de management es suficiente: si el puerto 8081 está expuesto en el security group o en el firewall de red, la separación no sirve.&lt;/li&gt;
&lt;li&gt;Que la sanitización de &lt;code&gt;env&lt;/code&gt; cubre todas las claves sensibles: Spring Boot sanitiza por patrones conocidos (&lt;code&gt;password&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;, &lt;code&gt;key&lt;/code&gt;, etc.), pero si usás nombres de variables no convencionales, vale verificarlo.&lt;/li&gt;
&lt;li&gt;Que agregar Spring Security sobre Actuator es la única solución: también podés resolver el acceso a nivel de red, de proxy o de ingress. La capa correcta depende de la arquitectura.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Si querés inspeccionar qué tráfico llega realmente a los endpoints de management en un ambiente local, una herramienta como &lt;a href="https://juanchi.dev/es/blog/sniffnet-monitor-trafico-red-ui-accesible-rust" rel="noopener noreferrer"&gt;Sniffnet&lt;/a&gt; puede ayudarte a visualizar conexiones sin meterte en tcpdump desde el primer momento.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: Spring Boot Actuator endpoints seguridad
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;¿Cuáles son los endpoints de Actuator que están habilitados por defecto?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Según la &lt;a href="https://docs.spring.io/spring-boot/reference/actuator/endpoints.html" rel="noopener noreferrer"&gt;documentación oficial&lt;/a&gt;, sobre HTTP solo &lt;code&gt;health&lt;/code&gt; e &lt;code&gt;info&lt;/code&gt; están expuestos por defecto. El resto están habilitados en el contexto pero no accesibles por HTTP. La excepción es &lt;code&gt;shutdown&lt;/code&gt;, que no está habilitado por defecto justamente por su impacto.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Qué diferencia hay entre habilitar y exponer un endpoint?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Habilitar significa que el endpoint existe en el contexto de Spring y puede ser invocado internamente. Exponer significa que es accesible desde el exterior (HTTP o JMX). Podés tener un endpoint habilitado y no expuesto: existe pero nadie puede llamarlo desde fuera. La propiedad &lt;code&gt;management.endpoints.web.exposure.include&lt;/code&gt; controla la exposición HTTP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Es suficiente con Spring Security para proteger Actuator?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Spring Security es una capa válida, pero no es la única ni necesariamente la primera línea. Separar el puerto de management y restringir acceso a ese puerto a nivel de red o proxy es una política más robusta porque no depende de la correcta configuración de cada regla de seguridad en el código. Idealmente, ambas capas conviven.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿&lt;code&gt;/actuator/health&lt;/code&gt; expone información sensible?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Con la configuración por defecto, &lt;code&gt;health&lt;/code&gt; devuelve solo el estado (&lt;code&gt;UP&lt;/code&gt; o &lt;code&gt;DOWN&lt;/code&gt;). El detalle de dependencias (base de datos, disco, etc.) aparece solo si configurás &lt;code&gt;show-details: always&lt;/code&gt;. La opción intermedia y más razonable en producción es &lt;code&gt;show-details: when-authorized&lt;/code&gt;: el sistema de monitoreo ve el detalle, el tráfico anónimo no.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Qué pasa si uso &lt;code&gt;include: "*"&lt;/code&gt; en producción?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Se exponen todos los endpoints habilitados, incluyendo &lt;code&gt;env&lt;/code&gt;, &lt;code&gt;configprops&lt;/code&gt;, &lt;code&gt;heapdump&lt;/code&gt; y potencialmente &lt;code&gt;shutdown&lt;/code&gt; si lo habilitaste. No es una configuración inválida en todos los contextos, pero requiere que el acceso al puerto de management esté completamente restringido a sistemas internos. Si ese puerto es accesible públicamente, cualquiera puede consultar variables de entorno o pedir un dump del heap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Cómo valido qué endpoints están realmente expuestos en mi app?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;El endpoint &lt;code&gt;/actuator&lt;/code&gt; devuelve un JSON con los endpoints activos y sus URLs. Es el primer lugar donde mirar. También podés revisar los logs de startup de Spring Boot: loguea los endpoints registrados si tenés el nivel adecuado. Y si tenés Prometheus o cualquier sistema de scraping configurado, verificá que solo raspe el puerto de management y no el puerto público.&lt;/p&gt;




&lt;h2&gt;
  
  
  Mi postura: Actuator es una herramienta operativa, no una opción de conveniencia
&lt;/h2&gt;

&lt;p&gt;Cuando vi por primera vez la anécdota de Docker que mencioné al principio del post sobre healthchecks, lo que me quedó fue la misma lección que aplica acá: el valor de la herramienta está en describir un contrato explícito, no en activarla y ver qué pasa.&lt;/p&gt;

&lt;p&gt;Con Actuator es igual. No es una feature que "agregás" a la app. Es una política de observabilidad que define qué información operativa se expone, a quién y desde dónde. Esa política puede ser tan simple como dos líneas en el YAML o tan elaborada como roles diferenciados con Spring Security, pero tiene que existir de forma consciente.&lt;/p&gt;

&lt;p&gt;Lo incómodo es que el tutorial que más circula muestra el &lt;code&gt;include: "*"&lt;/code&gt; como si fuera el paso natural. Y técnicamente funciona. Pero "funciona" y "es una decisión tomada con criterio" son cosas distintas, y la diferencia importa cuando el sistema crece.&lt;/p&gt;

&lt;p&gt;La decisión práctica para el próximo paso es esta: abrí el &lt;code&gt;application.yml&lt;/code&gt; del proyecto en el que estás trabajando ahora, buscá la sección &lt;code&gt;management.endpoints&lt;/code&gt;, y preguntate si lo que está ahí es una política explícita o el resultado de copiar un tutorial sin cuestionarlo. Si es lo segundo, dedicale quince minutos antes de que el sistema salga a un ambiente compartido.&lt;/p&gt;

&lt;p&gt;No porque Actuator sea peligroso. Sino porque las decisiones implícitas son las que se acumulan.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Fuentes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.spring.io/spring-boot/reference/actuator/endpoints.html" rel="noopener noreferrer"&gt;Spring Boot Actuator — Endpoints (documentación oficial)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Este artículo fue publicado originalmente en &lt;a href="https://juanchi.dev/es/blog/spring-boot-actuator-endpoints-seguridad-3" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>espanol</category>
      <category>devops</category>
      <category>backend</category>
    </item>
    <item>
      <title>Next.js App Router Caching: revalidate, dynamic, and no-store Without the Folklore</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Sun, 05 Jul 2026 12:00:55 +0000</pubDate>
      <link>https://dev.to/jtorchia/nextjs-app-router-caching-revalidate-dynamic-and-no-store-without-the-folklore-in1</link>
      <guid>https://dev.to/jtorchia/nextjs-app-router-caching-revalidate-dynamic-and-no-store-without-the-folklore-in1</guid>
      <description>&lt;h1&gt;
  
  
  Next.js App Router Caching: revalidate, dynamic, and no-store Without the Folklore
&lt;/h1&gt;

&lt;p&gt;There's a very specific moment in App Router development that I know all too well: someone adds &lt;code&gt;export const dynamic = 'force-dynamic'&lt;/code&gt; to a page, the build passes, and everyone breathes again. The data is fresh. The problem... is that nobody knows exactly why it works, or when it stops being the right answer.&lt;/p&gt;

&lt;p&gt;That's folklore. And folklore in caching is expensive — you pay for it either with stale data when you don't want it, or with renders that skip the cache when they could've used it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My thesis is this&lt;/strong&gt;: the problem isn't memorizing the caching flags in Next.js. It's deciding how fresh each piece of data needs to be &lt;em&gt;before&lt;/em&gt; you even open the docs. If you start from the flag, you're solving the problem backwards.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the Official Docs Say — and What They Don't
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://nextjs.org/docs/app/building-your-application/caching" rel="noopener noreferrer"&gt;Next.js caching documentation&lt;/a&gt; describes four cache layers that interact with each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Request Memoization&lt;/strong&gt; — deduplication of &lt;code&gt;fetch&lt;/code&gt; calls with the same URL within a single render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data Cache&lt;/strong&gt; — persistence of &lt;code&gt;fetch&lt;/code&gt; responses across requests (configurable with &lt;code&gt;revalidate&lt;/code&gt; or &lt;code&gt;no-store&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full Route Cache&lt;/strong&gt; — static HTML + RSC payload generated at build time or runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Router Cache&lt;/strong&gt; — client-side cache for prefetching segments.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What the docs explain well: how to configure each layer, what options exist, what semantics they carry.&lt;/p&gt;

&lt;p&gt;What the docs &lt;strong&gt;don't say&lt;/strong&gt;: which one to pick based on the type of data you're serving. That's a product decision, not an API decision. And that's exactly where most people get lost.&lt;/p&gt;

&lt;p&gt;The docs are honest about the system's limits, but not prescriptive about when each option actually makes sense. That part is on you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Read the Flags as Data Contracts, Not Tricks
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of configuration, the right question isn't "which flag do I use?" It's:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How old can this data get before the user notices something is wrong?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's the data contract. And every caching option in Next.js is a way to declare that contract explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;revalidate: N&lt;/code&gt; (ISR — time-based revalidation)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Data that changes, but not every second&lt;/span&gt;
&lt;span class="c1"&gt;// e.g.: blog posts, catalog prices with low turnover&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;revalidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="c1"&gt;// revalidate every hour&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Contract&lt;/strong&gt;: "This data can be up to N seconds old. I'm okay with that."&lt;/p&gt;

&lt;p&gt;When it makes sense: editorial content, product catalogs that don't change hourly, landing pages with semi-structured data.&lt;/p&gt;

&lt;p&gt;When it &lt;strong&gt;doesn't&lt;/strong&gt; make sense: real-time stock, user-personalized data, operational dashboards.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;dynamic = 'force-dynamic'&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Every request triggers a fresh server render&lt;/span&gt;
&lt;span class="c1"&gt;// Equivalent to getServerSideProps in Pages Router&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;force-dynamic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Contract&lt;/strong&gt;: "This data can't tolerate any staleness. I want it fresh on every request."&lt;/p&gt;

&lt;p&gt;The uncomfortable truth about this flag: it's the easiest to slap on and the most expensive to sustain. No Full Route Cache, no Data Cache for this segment. Every single user pays the full cost of a fresh render. That can be exactly the right call — but it has to be a decision, not the default when something isn't working.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;cache: 'no-store'&lt;/code&gt; on fetch
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// At the individual fetch level — more granular than dynamic&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com/sensitive-data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// never cache this response&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Contract&lt;/strong&gt;: "This specific piece of data should never be cached, even if other data in the same component can be."&lt;/p&gt;

&lt;p&gt;The difference from &lt;code&gt;force-dynamic&lt;/code&gt; is scope. &lt;code&gt;no-store&lt;/code&gt; is surgical: it affects one specific fetch. &lt;code&gt;force-dynamic&lt;/code&gt; disables the cache for the entire segment. If you need one fresh piece of data inside a page that otherwise has cacheable content, &lt;code&gt;no-store&lt;/code&gt; on the fetch is the right tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  No configuration (default)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// No revalidate, no dynamic, no cache: 'no-store'&lt;/span&gt;
&lt;span class="c1"&gt;// Next.js caches indefinitely at build time (or until manual invalidation)&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com/static-content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Contract&lt;/strong&gt;: "This data is static. Generate once, serve forever, invalidate when the code changes."&lt;/p&gt;

&lt;p&gt;Ideal for content that doesn't change between deploys: legal pages, documentation, institutional landing pages.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where People Go Wrong (and the Hidden Cost)
&lt;/h2&gt;

&lt;p&gt;The most common mistake I see in App Router projects isn't picking the wrong flag. It's &lt;strong&gt;not choosing consciously&lt;/strong&gt; and letting the default decide.&lt;/p&gt;

&lt;p&gt;Three problematic patterns:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;force-dynamic&lt;/code&gt; as a universal escape hatch&lt;/strong&gt;&lt;br&gt;
When something "isn't updating properly," the first instinct is to throw &lt;code&gt;force-dynamic&lt;/code&gt; at it. It works. But if the data could tolerate 5 minutes of staleness, you're paying the full render-per-request cost for no reason. There's no magic number here — it depends on your traffic volume and infrastructure — but the cost is real and worth measuring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Mixing &lt;code&gt;revalidate&lt;/code&gt; in layout and page without understanding inheritance&lt;/strong&gt;&lt;br&gt;
The docs are clear: the most restrictive segment wins. If the layout has &lt;code&gt;revalidate = 0&lt;/code&gt; (equivalent to &lt;code&gt;force-dynamic&lt;/code&gt;) and the page has &lt;code&gt;revalidate = 3600&lt;/code&gt;, the segment will render dynamically regardless. This catches a lot of people off guard because the page config "looks like it's being ignored."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Assuming &lt;code&gt;revalidate&lt;/code&gt; is exact&lt;/strong&gt;&lt;br&gt;
ISR with &lt;code&gt;revalidate: 60&lt;/code&gt; does not guarantee your data updates exactly at 60 seconds. Revalidation is &lt;em&gt;stale-while-revalidate&lt;/em&gt;: the first request after expiration returns stale data and triggers a background regeneration. The next request — which could be milliseconds or minutes later depending on traffic — gets the fresh data. If your system needs strict time-based consistency, ISR is not the right tool.&lt;/p&gt;


&lt;h2&gt;
  
  
  Decision Matrix: Which Flag Does Each Data Need
&lt;/h2&gt;

&lt;p&gt;Before touching any configuration, answer these questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer → option&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Does the data change between deploys?&lt;/td&gt;
&lt;td&gt;No → default (static)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can it tolerate N seconds/minutes of staleness?&lt;/td&gt;
&lt;td&gt;Yes → &lt;code&gt;revalidate: N&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does it need to be fresh on each request, but only this one fetch?&lt;/td&gt;
&lt;td&gt;Yes → &lt;code&gt;cache: 'no-store'&lt;/code&gt; on the fetch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does the entire segment need to be fresh on every request?&lt;/td&gt;
&lt;td&gt;Yes → &lt;code&gt;dynamic = 'force-dynamic'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Does the data depend on the authenticated user, headers, or cookies?&lt;/td&gt;
&lt;td&gt;Yes → &lt;code&gt;force-dynamic&lt;/code&gt; or &lt;code&gt;cookies()&lt;/code&gt;/&lt;code&gt;headers()&lt;/code&gt; (they activate dynamic automatically)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A note on that last row: in App Router, calling &lt;code&gt;cookies()&lt;/code&gt; or &lt;code&gt;headers()&lt;/code&gt; inside a Server Component automatically activates dynamic rendering for that segment, even if you never declare &lt;code&gt;force-dynamic&lt;/code&gt;. This is documented behavior, but it surprises people constantly.&lt;/p&gt;


&lt;h2&gt;
  
  
  Reference Snippet: ISR with On-Demand Revalidation
&lt;/h2&gt;

&lt;p&gt;This pattern combines time-based &lt;code&gt;revalidate&lt;/code&gt; with on-demand revalidation via a Route Handler. It's useful when content changes unpredictably but you can fire an invalidation from a webhook or CMS:&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="c1"&gt;// app/blog/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;revalidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="c1"&gt;// fallback: revalidate every hour&lt;/span&gt;

&lt;span class="c1"&gt;// app/api/revalidate/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidatePath&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;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&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;// Validate that the request comes from a trusted source&lt;/span&gt;
  &lt;span class="c1"&gt;// (e.g.: secret token from the CMS)&lt;/span&gt;
  &lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&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;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="na"&gt;revalidated&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;This isn't a production-ready recipe — it's a starting point. Webhook authentication, error handling, and path strategy are decisions that depend on your specific system.&lt;/p&gt;




&lt;h2&gt;
  
  
  Honest Limits: What You Can't Conclude Without Real Data
&lt;/h2&gt;

&lt;p&gt;Before wrapping up, I want to be clear about what this guide &lt;strong&gt;does not prove&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;There are no performance benchmarks here. The impact depends on request volume, deploy infrastructure (Vercel, Railway, self-hosted), and the response time of your external data sources. Without measurement in a real context, any number is folklore.&lt;/li&gt;
&lt;li&gt;The Router Cache (client) interacts with the Full Route Cache (server) in ways that can genuinely surprise you. The official docs describe the cases, but the concrete behavior in an app with complex navigation is worth validating with Next.js DevTools or server logs.&lt;/li&gt;
&lt;li&gt;ISR in self-hosted environments doesn't behave exactly the same as on Vercel. Some on-demand revalidation features assume specific infrastructure. Read the Next.js deploy docs for your platform before assuming behavioral parity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're making a caching architecture decision for a critical system, these limits matter. Measure before you assume.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: Next.js App Router Caching
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between &lt;code&gt;cache: 'no-store'&lt;/code&gt; and &lt;code&gt;dynamic = 'force-dynamic'&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
Scope. &lt;code&gt;cache: 'no-store'&lt;/code&gt; affects a single fetch: that data doesn't cache, but the rest of the segment can still cache normally. &lt;code&gt;force-dynamic&lt;/code&gt; disables the Full Route Cache for the entire segment: every request triggers a fresh server render, regardless of how individual fetches are configured. Use &lt;code&gt;no-store&lt;/code&gt; when you need surgical precision; &lt;code&gt;force-dynamic&lt;/code&gt; when the entire segment depends on fresh data or request context (user, cookies, headers).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is &lt;code&gt;revalidate: 0&lt;/code&gt; the same as &lt;code&gt;force-dynamic&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
In practice, yes. The Next.js docs indicate that &lt;code&gt;revalidate: 0&lt;/code&gt; opts into dynamic rendering. But for clarity of intent, &lt;code&gt;force-dynamic&lt;/code&gt; is more explicit and less ambiguous for whoever reads the code later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if I have different &lt;code&gt;revalidate&lt;/code&gt; values in the layout and the page?&lt;/strong&gt;&lt;br&gt;
The most restrictive value wins (the lowest one). If the layout has &lt;code&gt;revalidate = 60&lt;/code&gt; and the page has &lt;code&gt;revalidate = 3600&lt;/code&gt;, the segment revalidates every 60 seconds. This can make the page config look ignored — but it's documented behavior. Always check the configuration of layouts wrapping your pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I know if a Server Component is rendering statically or dynamically?&lt;/strong&gt;&lt;br&gt;
Run &lt;code&gt;next build&lt;/code&gt; and look at the terminal output: static routes are marked (●), dynamic (λ), and ISR (◐). You can also use the Next.js dev toolbar in local development, which shows the render mode for each segment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the client Router Cache interfere with my server-side configurations?&lt;/strong&gt;&lt;br&gt;
Yes, and it's a common source of confusion. The Router Cache stores visited segments on the client for a configurable duration — separate from the server cache. You can control this with &lt;code&gt;staleTimes&lt;/code&gt; in your Next.js config (available since Next.js 14.2). If data looks stale on back navigation even though the server is revalidating correctly, the Router Cache is your first suspect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does ISR work the same in all deploy environments?&lt;/strong&gt;&lt;br&gt;
Not exactly. On Vercel, ISR has native CDN-level support. In self-hosted environments (Railway, VPS, Docker), revalidation works but depends on the Node.js server implementation and doesn't necessarily distribute the cache across instances. The Next.js docs have a specific section on self-hosting that's worth reading before assuming behavioral parity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing: The Decision Comes Before the Flag
&lt;/h2&gt;

&lt;p&gt;Next.js App Router caching isn't complicated when you read it as a system of contracts. Each option declares something about the staleness the data can tolerate. The work isn't memorizing which flag does what — the docs solve that in two minutes. The work is knowing, before you write a single line, how old each piece of information you're serving is allowed to get.&lt;/p&gt;

&lt;p&gt;If you have data that changes every 5 minutes and you're serving it with the static default, you're breaking the contract without realizing it. If you have editorial content that could cache for hours and you're using &lt;code&gt;force-dynamic&lt;/code&gt;, you're paying a cost nobody consciously approved.&lt;/p&gt;

&lt;p&gt;My practical recommendation: before touching the configuration of any page or layout, write a comment answering "how old can this data get?" If you can't answer that question, the technical conversation hasn't started yet.&lt;/p&gt;

&lt;p&gt;The concrete next step: open the &lt;code&gt;next build&lt;/code&gt; output and look at which routes are static, which are dynamic, and which are ISR. If there are surprises, that's where the real investigation begins.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Original sources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/building-your-application/caching" rel="noopener noreferrer"&gt;Next.js caching docs — App Router&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;If you're into the idea of implicit contracts in infrastructure, I have related posts on &lt;a href="https://juanchi.dev/en/blog/docker-healthcheck-what-it-measures-decision-matrix" rel="noopener noreferrer"&gt;Docker healthchecks and what they actually measure&lt;/a&gt; and &lt;a href="https://juanchi.dev/en/blog/digital-signature-format-certificate-validation-policy-2" rel="noopener noreferrer"&gt;digital signatures: the difference between format, certificate, and validation policy&lt;/a&gt; that follow the same logic.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://juanchi.dev/en/blog/nextjs-app-router-caching-revalidate-dynamic-no-store-3" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>english</category>
      <category>react</category>
      <category>typescript</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Next.js App Router caching: revalidate, dynamic y no-store sin folklore</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Sun, 05 Jul 2026 12:00:50 +0000</pubDate>
      <link>https://dev.to/jtorchia/nextjs-app-router-caching-revalidate-dynamic-y-no-store-sin-folklore-2njn</link>
      <guid>https://dev.to/jtorchia/nextjs-app-router-caching-revalidate-dynamic-y-no-store-sin-folklore-2njn</guid>
      <description>&lt;h1&gt;
  
  
  Next.js App Router caching: revalidate, dynamic y no-store sin folklore
&lt;/h1&gt;

&lt;p&gt;Hay un momento muy específico en el desarrollo con App Router que conozco bien: alguien agrega &lt;code&gt;export const dynamic = 'force-dynamic'&lt;/code&gt; a una page, el build pasa, y todos respiran. El dato está fresco. El problema... es que nadie sabe exactamente por qué funciona, ni cuándo deja de ser la respuesta correcta.&lt;/p&gt;

&lt;p&gt;Eso es folklore. Y el folklore en caching se paga caro: o con datos viejos cuando no querés, o con renders que no cachean cuando podrían.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mi tesis es esta&lt;/strong&gt;: el problema no es memorizar los flags de caching en Next.js. Es decidir la frescura que necesita cada dato antes de agarrar la documentación. Si arrancás desde el flag, estás resolviendo el problema al revés.&lt;/p&gt;




&lt;h2&gt;
  
  
  Qué dice la documentación oficial y qué NO dice
&lt;/h2&gt;

&lt;p&gt;La &lt;a href="https://nextjs.org/docs/app/building-your-application/caching" rel="noopener noreferrer"&gt;documentación de caching de Next.js&lt;/a&gt; describe cuatro capas de cache que interactúan entre sí:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Request Memoization&lt;/strong&gt; — deduplicación de &lt;code&gt;fetch&lt;/code&gt; con mismo URL dentro de un render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data Cache&lt;/strong&gt; — persistencia de respuestas de &lt;code&gt;fetch&lt;/code&gt; entre requests (configurable con &lt;code&gt;revalidate&lt;/code&gt; o &lt;code&gt;no-store&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full Route Cache&lt;/strong&gt; — HTML + RSC payload estático generado en build o en runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Router Cache&lt;/strong&gt; — cache del lado del cliente para prefetching de segmentos.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Lo que la doc explica bien: cómo configurar cada capa, qué opciones existen, qué semántica tienen.&lt;/p&gt;

&lt;p&gt;Lo que la doc &lt;strong&gt;no dice&lt;/strong&gt;: cuál elegir según el tipo de dato que estás sirviendo. Eso es una decisión de producto, no de API. Y ahí es exactamente donde la mayoría se pierde.&lt;/p&gt;

&lt;p&gt;La doc es honesta sobre los límites del sistema, pero no prescriptiva sobre cuándo cada opción tiene sentido. Esa parte la ponés vos.&lt;/p&gt;




&lt;h2&gt;
  
  
  Leer los flags como contratos de datos, no como trucos
&lt;/h2&gt;

&lt;p&gt;Antes de escribir una sola línea de configuración, la pregunta correcta no es "¿qué flag uso?". Es:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;¿Qué tan viejo puede estar este dato antes de que el usuario note que algo está mal?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Eso es el contrato de datos. Y cada opción de caching en Next.js es una manera de declarar ese contrato explícitamente:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;revalidate: N&lt;/code&gt; (ISR — revalidación por tiempo)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Datos que cambian, pero no cada segundo&lt;/span&gt;
&lt;span class="c1"&gt;// Ej: posts de un blog, precios de catálogo con baja rotación&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;revalidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="c1"&gt;// revalida cada hora&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Contrato&lt;/strong&gt;: "Este dato puede tener hasta N segundos de antigüedad. Estoy de acuerdo con eso."&lt;/p&gt;

&lt;p&gt;Cuando tiene sentido: contenido editorial, catálogos de productos que no cambian por hora, páginas de landing con datos semiestructurados.&lt;/p&gt;

&lt;p&gt;Cuando &lt;strong&gt;no&lt;/strong&gt; tiene sentido: stock en tiempo real, datos personalizados por usuario, dashboards operativos.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;dynamic = 'force-dynamic'&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Cada request genera un render nuevo en el servidor&lt;/span&gt;
&lt;span class="c1"&gt;// Equivalente a getServerSideProps en Pages Router&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;force-dynamic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Contrato&lt;/strong&gt;: "Este dato no puede tener ninguna antigüedad tolerable. Lo quiero fresco en cada request."&lt;/p&gt;

&lt;p&gt;Lo incómodo de este flag: es el más fácil de poner y el más caro de sostener. No hay Full Route Cache, no hay Data Cache para este segmento. Cada usuario que entra paga el costo de un render completo. Eso puede ser exactamente lo correcto — pero tiene que ser una decisión, no el default cuando algo no funciona.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;cache: 'no-store'&lt;/code&gt; en fetch
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A nivel de fetch individual — más granular que dynamic&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.ejemplo.com/datos-sensibles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// nunca cachear esta respuesta&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Contrato&lt;/strong&gt;: "Este dato específico nunca debe cachear, aunque otros datos en el mismo componente sí puedan."&lt;/p&gt;

&lt;p&gt;La diferencia con &lt;code&gt;force-dynamic&lt;/code&gt; es el alcance. &lt;code&gt;no-store&lt;/code&gt; es quirúrgico: afecta un fetch puntual. &lt;code&gt;force-dynamic&lt;/code&gt; desactiva el cache del segmento completo. Si necesitás un dato fresco en medio de una page que tiene otros datos cacheables, &lt;code&gt;no-store&lt;/code&gt; en el fetch es la herramienta correcta.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sin configuración (default)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Sin revalidate, sin dynamic, sin cache: 'no-store'&lt;/span&gt;
&lt;span class="c1"&gt;// Next.js cachea indefinidamente en build time (o hasta invalidación manual)&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.ejemplo.com/contenido-estatico&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Contrato&lt;/strong&gt;: "Este dato es estático. Genera una vez, servís siempre, invalidás cuando cambia el código."&lt;/p&gt;

&lt;p&gt;Ideal para contenido que no cambia entre deploys: páginas legales, documentación, landings institucionales.&lt;/p&gt;




&lt;h2&gt;
  
  
  Donde se equivoca la gente (y el costo oculto)
&lt;/h2&gt;

&lt;p&gt;El error más común que veo en proyectos con App Router no es elegir el flag equivocado. Es &lt;strong&gt;no elegir conscientemente&lt;/strong&gt; y dejar que el default decida.&lt;/p&gt;

&lt;p&gt;Tres patrones problemáticos:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;force-dynamic&lt;/code&gt; como escape hatch universal&lt;/strong&gt;&lt;br&gt;
Cuando algo "no actualiza bien", el primer instinto es poner &lt;code&gt;force-dynamic&lt;/code&gt;. Funciona. Pero si el dato podría tolerar 5 minutos de antigüedad, estás pagando el costo de un render por request sin necesidad. No hay número mágico aquí — depende del volumen del sitio y de la infraestructura — pero el costo existe y vale medirlo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Mezclar &lt;code&gt;revalidate&lt;/code&gt; en layout y en page sin entender la herencia&lt;/strong&gt;&lt;br&gt;
La documentación aclara: el segmento más restrictivo gana. Si el layout tiene &lt;code&gt;revalidate = 0&lt;/code&gt; (equivalente a &lt;code&gt;force-dynamic&lt;/code&gt;) y la page tiene &lt;code&gt;revalidate = 3600&lt;/code&gt;, el segmento va a renderizar dinámicamente igual. Esto sorprende a mucha gente porque la configuración de la page "parece ignorada".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Asumir que &lt;code&gt;revalidate&lt;/code&gt; es exacto&lt;/strong&gt;&lt;br&gt;
ISR con &lt;code&gt;revalidate: 60&lt;/code&gt; no garantiza que el dato se actualice exactamente a los 60 segundos. La revalidación es &lt;em&gt;stale-while-revalidate&lt;/em&gt;: el primer request después de expirar devuelve el dato viejo y dispara la regeneración en background. El siguiente request —que puede ser milisegundos o minutos después, según el tráfico— ya trae el dato nuevo. Si el sistema necesita consistencia estricta por tiempo, ISR no es la herramienta correcta.&lt;/p&gt;


&lt;h2&gt;
  
  
  Matriz de decisión: qué flag necesita cada dato
&lt;/h2&gt;

&lt;p&gt;Antes de tocar la configuración, respondé estas tres preguntas:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pregunta&lt;/th&gt;
&lt;th&gt;Respuesta → opción&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;¿El dato cambia entre deploys?&lt;/td&gt;
&lt;td&gt;No → default (estático)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;¿Puede tolerar N segundos/minutos de antigüedad?&lt;/td&gt;
&lt;td&gt;Sí → &lt;code&gt;revalidate: N&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;¿Necesita ser fresco en cada request, pero solo este fetch?&lt;/td&gt;
&lt;td&gt;Sí → &lt;code&gt;cache: 'no-store'&lt;/code&gt; en el fetch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;¿Todo el segmento necesita ser fresco en cada request?&lt;/td&gt;
&lt;td&gt;Sí → &lt;code&gt;dynamic = 'force-dynamic'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;¿El dato depende del usuario autenticado o de headers/cookies?&lt;/td&gt;
&lt;td&gt;Sí → &lt;code&gt;force-dynamic&lt;/code&gt; o &lt;code&gt;cookies()&lt;/code&gt;/&lt;code&gt;headers()&lt;/code&gt; (activan dynamic automáticamente)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Una nota sobre la última fila: en App Router, usar &lt;code&gt;cookies()&lt;/code&gt; o &lt;code&gt;headers()&lt;/code&gt; dentro de un Server Component activa automáticamente el renderizado dinámico para ese segmento, aunque no declares &lt;code&gt;force-dynamic&lt;/code&gt;. Esto es un comportamiento documentado que vale tener claro.&lt;/p&gt;


&lt;h2&gt;
  
  
  Snippet de referencia: ISR con revalidación por demanda
&lt;/h2&gt;

&lt;p&gt;Este patrón combina &lt;code&gt;revalidate&lt;/code&gt; por tiempo con revalidación por demanda vía Route Handler. Es útil cuando el contenido cambia de forma impredecible pero podés disparar una invalidación desde un webhook o un CMS:&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="c1"&gt;// app/blog/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;revalidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="c1"&gt;// fallback: revalida cada hora&lt;/span&gt;

&lt;span class="c1"&gt;// app/api/revalidate/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidatePath&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;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&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;// Validar que el request viene de una fuente confiable&lt;/span&gt;
  &lt;span class="c1"&gt;// (ej: token secreto del CMS)&lt;/span&gt;
  &lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&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;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="na"&gt;revalidated&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;No es una receta lista para producción — es un punto de partida. La validación de autenticidad del webhook, el manejo de errores y la estrategia de paths son decisiones que dependen de cada sistema.&lt;/p&gt;




&lt;h2&gt;
  
  
  Límites honestos: qué no podés concluir sin datos reales
&lt;/h2&gt;

&lt;p&gt;Antes de cerrar, quiero ser claro sobre lo que esta guía &lt;strong&gt;no prueba&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No hay benchmarks de rendimiento entre opciones. El impacto depende del volumen de requests, la infraestructura de deploy (Vercel, Railway, self-hosted) y el tiempo de respuesta de los datos externos. Sin medición en contexto real, cualquier número es folklore.&lt;/li&gt;
&lt;li&gt;El comportamiento del Router Cache (cliente) interactúa con el Full Route Cache (servidor) de maneras que pueden sorprender. La documentación oficial describe los casos, pero el comportamiento concreto en una app con navegación compleja conviene validarlo con las DevTools de Next.js o con logs.&lt;/li&gt;
&lt;li&gt;ISR en entornos self-hosted no se comporta exactamente igual que en Vercel. Algunas características de revalidación por demanda asumen infraestructura específica. Revisá la documentación de deploy de Next.js para vos plataforma.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Si estás tomando una decisión de arquitectura de caching en un sistema crítico, estos límites importan. Medí antes de asumir.&lt;/p&gt;




&lt;h2&gt;
  
  
  FAQ: Next.js App Router caching
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;¿Cuál es la diferencia entre &lt;code&gt;cache: 'no-store'&lt;/code&gt; y &lt;code&gt;dynamic = 'force-dynamic'&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
El alcance. &lt;code&gt;cache: 'no-store'&lt;/code&gt; afecta un fetch individual: ese dato no cachea, pero el resto del segmento puede seguir cacheando. &lt;code&gt;force-dynamic&lt;/code&gt; desactiva el Full Route Cache para el segmento completo: cada request hace un render nuevo en el servidor, sin importar cómo configuren los fetches individuales. Usá &lt;code&gt;no-store&lt;/code&gt; cuando necesitás precisión quirúrgica; &lt;code&gt;force-dynamic&lt;/code&gt; cuando todo el segmento depende de datos frescos o del contexto del request (usuario, cookies, headers).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿&lt;code&gt;revalidate: 0&lt;/code&gt; es lo mismo que &lt;code&gt;force-dynamic&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
En la práctica, sí. La documentación de Next.js indica que &lt;code&gt;revalidate: 0&lt;/code&gt; equivale a optar por renderizado dinámico. Pero para mayor claridad de intención en el código, &lt;code&gt;force-dynamic&lt;/code&gt; es más explícito y menos ambiguo para quien lee el código después.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Qué pasa si tengo &lt;code&gt;revalidate&lt;/code&gt; diferente en el layout y en la page?&lt;/strong&gt;&lt;br&gt;
Gana el valor más restrictivo (el más bajo). Si el layout tiene &lt;code&gt;revalidate = 60&lt;/code&gt; y la page tiene &lt;code&gt;revalidate = 3600&lt;/code&gt;, el segmento revalida cada 60 segundos. Esto puede hacer que la configuración de la page parezca ignorada, pero es el comportamiento documentado. Revisá siempre la configuración de los layouts que envuelven los pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Cómo sé si un Server Component está renderizando estático o dinámico?&lt;/strong&gt;&lt;br&gt;
Con &lt;code&gt;next build&lt;/code&gt; podés ver en la salida del terminal qué rutas quedaron estáticas (●), dinámicas (λ) o ISR (◐). También podés usar la barra de herramientas de desarrollo de Next.js en desarrollo local, que indica el modo de renderizado de cada segmento.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿El Router Cache del cliente interfiere con mis configuraciones de servidor?&lt;/strong&gt;&lt;br&gt;
Sí, y es una fuente común de confusión. El Router Cache guarda en el cliente los segmentos visitados por un tiempo configurable (distinto al cache del servidor). Podés controlar esto con &lt;code&gt;staleTimes&lt;/code&gt; en la configuración de Next.js (disponible desde Next.js 14.2). Si los datos parecen viejos en navegación de vuelta atrás aunque el servidor esté revalidando, el Router Cache es el primer sospechoso.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿ISR funciona igual en todos los entornos de deploy?&lt;/strong&gt;&lt;br&gt;
No exactamente. En Vercel, ISR tiene soporte nativo a nivel de CDN. En entornos self-hosted (Railway, VPS, Docker), la revalidación funciona pero depende de la implementación del servidor de Node.js y no necesariamente distribuye el cache entre instancias. La documentación de Next.js tiene una sección específica sobre self-hosting que vale leer antes de asumir paridad de comportamiento.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cierre: la decisión antes del flag
&lt;/h2&gt;

&lt;p&gt;El caching de Next.js App Router no es complicado cuando lo leés como un sistema de contratos. Cada opción declara algo sobre la frescura que acepta el dato. El trabajo no es memorizar cuál flag hace qué — eso lo resuelve la documentación en dos minutos. El trabajo es tener claro, antes de escribir una línea, cuánta antigüedad puede tolerar cada pieza de información que servís.&lt;/p&gt;

&lt;p&gt;Si tenés un dato que cambia cada 5 minutos y lo estás sirviendo con el default estático, estás rompiendo el contrato sin saberlo. Si tenés contenido editorial que podría cachear por horas y usás &lt;code&gt;force-dynamic&lt;/code&gt;, estás pagando un costo que nadie aprobó conscientemente.&lt;/p&gt;

&lt;p&gt;Mi recomendación práctica: antes de tocar la configuración de cualquier page o layout, escribí en un comentario la respuesta a "¿qué tan viejo puede estar este dato?". Si no podés responder esa pregunta, la conversación técnica no arrancó todavía.&lt;/p&gt;

&lt;p&gt;El próximo paso concreto: abrí el output de &lt;code&gt;next build&lt;/code&gt; y mirá qué rutas quedaron estáticas, cuáles dinámicas y cuáles ISR. Si hay sorpresas, ahí empieza la investigación real.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Fuentes originales:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/building-your-application/caching" rel="noopener noreferrer"&gt;Next.js caching docs — App Router&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Si te interesa el tema de contratos implícitos en infraestructura, tengo posts relacionados sobre &lt;a href="https://juanchi.dev/es/blog/docker-healthcheck-buenas-practicas-que-miden-y-que-no" rel="noopener noreferrer"&gt;Docker healthchecks y qué miden de verdad&lt;/a&gt; y &lt;a href="https://juanchi.dev/es/blog/firma-digital-formato-certificado-politica-validacion-3" rel="noopener noreferrer"&gt;firma digital: la diferencia entre formato, certificado y política de validación&lt;/a&gt; que siguen la misma lógica.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Este artículo fue publicado originalmente en &lt;a href="https://juanchi.dev/es/blog/nextjs-app-router-caching-revalidate-dynamic-no-store-3" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>espanol</category>
      <category>react</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Docker healthchecks: what they actually measure and what you shouldn't promise</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Fri, 03 Jul 2026 12:01:57 +0000</pubDate>
      <link>https://dev.to/jtorchia/docker-healthchecks-what-they-actually-measure-and-what-you-shouldnt-promise-447k</link>
      <guid>https://dev.to/jtorchia/docker-healthchecks-what-they-actually-measure-and-what-you-shouldnt-promise-447k</guid>
      <description>&lt;h1&gt;
  
  
  Docker healthchecks: what they actually measure and what you shouldn't promise
&lt;/h1&gt;

&lt;p&gt;Why do we treat a Docker healthcheck like it's a full health monitoring system when the official documentation promises nothing of the sort? You've been running containers for months, the Railway dashboard shows "healthy" in green, and somehow you assume the app is fine. That assumption bothers me. And I think it's worth putting it under pressure.&lt;/p&gt;

&lt;p&gt;My thesis is straightforward: &lt;strong&gt;a healthcheck that only confirms the process responds can hide entire business failures&lt;/strong&gt;. The mechanism does what it says it does — nothing more, nothing less. The problem is what we promise on top of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Docker healthcheck: what the docs say and what they DON'T
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://docs.docker.com/reference/dockerfile/#healthcheck" rel="noopener noreferrer"&gt;official HEALTHCHECK reference&lt;/a&gt; is clear about its scope. The &lt;code&gt;HEALTHCHECK&lt;/code&gt; instruction defines a command that Docker runs periodically inside the container to know whether that container "works." If the command returns exit code &lt;code&gt;0&lt;/code&gt;, the container is &lt;code&gt;healthy&lt;/code&gt;. If it returns &lt;code&gt;1&lt;/code&gt;, it's &lt;code&gt;unhealthy&lt;/code&gt;. If it returns &lt;code&gt;2&lt;/code&gt;, it's ignored (reserved code).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Minimal example from the official documentation&lt;/span&gt;
&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=10s --start-period=15s --retries=3 \&lt;/span&gt;
  CMD curl -f http://localhost:3000/health || exit 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The available parameters are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--interval&lt;/code&gt;: how often it runs (default: 30s)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--timeout&lt;/code&gt;: maximum time for the command to respond (default: 30s)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--start-period&lt;/code&gt;: initial grace period for the container to start up (default: 0s)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--retries&lt;/code&gt;: how many consecutive failures mark it &lt;code&gt;unhealthy&lt;/code&gt; (default: 3)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the documentation &lt;strong&gt;doesn't say&lt;/strong&gt; is that the &lt;code&gt;/health&lt;/code&gt; endpoint verifies the real state of the application. That's the responsibility of whoever implements it. Docker only evaluates the exit code of the command — it doesn't read the response body, doesn't parse JSON, doesn't know if the database is down or if a queue is accumulating unprocessed messages.&lt;/p&gt;

&lt;p&gt;The uncomfortable truth: most examples floating around in tutorials use &lt;code&gt;curl -f&lt;/code&gt; against an endpoint that returns &lt;code&gt;{"status":"ok"}&lt;/code&gt; without connecting to anything real. It's a healthcheck that only confirms the Node, Go, or Java process started and is accepting TCP connections. That has value — but it's very limited value.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where people go wrong: the common recipe and its hidden cost
&lt;/h2&gt;

&lt;p&gt;The most frequent pattern showing up in public Docker setups looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Common pattern — measures TCP, not business logic&lt;/span&gt;
&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=5s --retries=3 \&lt;/span&gt;
  CMD curl -f http://localhost:8080/health || exit 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on the app side, the endpoint does something 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="c1"&gt;// Handler that only confirms the server is responding&lt;/span&gt;
&lt;span class="nx"&gt;app&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;/health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container shows up as &lt;code&gt;healthy&lt;/code&gt;. The orchestrator doesn't restart it. Railway shows green. And meanwhile, the PostgreSQL connection could be timing out, the connection pool exhausted, or a critical worker silently dead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hidden cost isn't that the healthcheck lies — it's that we believe it more than we should&lt;/strong&gt;. When a team assumes "healthy = app is fine," they stop looking at logs, metrics, and richer signals. The healthcheck becomes the invisible scapegoat: if the dashboard is green, the problem must be somewhere else.&lt;/p&gt;

&lt;p&gt;There's a counterexample that applies to any typical system: imagine a backend that processes payments. The &lt;code&gt;/health&lt;/code&gt; endpoint returns 200, the container is &lt;code&gt;healthy&lt;/code&gt;, but the payment gateway client has an expired token. Every transaction fails silently. Docker knows nothing about that — and it shouldn't have to, if the endpoint doesn't check for it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Decision matrix: when to trust the healthcheck and when to go further
&lt;/h2&gt;

&lt;p&gt;This matrix isn't absolute — it's a sensible criterion for deciding what to verify and from where:&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;What it detects&lt;/th&gt;
&lt;th&gt;What it DOESN'T detect&lt;/th&gt;
&lt;th&gt;Best tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Basic &lt;code&gt;HEALTHCHECK&lt;/code&gt; (curl TCP)&lt;/td&gt;
&lt;td&gt;Process started, port open&lt;/td&gt;
&lt;td&gt;DB failures, broken logic, queues&lt;/td&gt;
&lt;td&gt;First-level triage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/health&lt;/code&gt; endpoint with DB ping&lt;/td&gt;
&lt;td&gt;Database connectivity&lt;/td&gt;
&lt;td&gt;Corrupt data, permissions, migrations&lt;/td&gt;
&lt;td&gt;Basic readiness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deep &lt;code&gt;/health&lt;/code&gt; endpoint&lt;/td&gt;
&lt;td&gt;Critical dependencies responding&lt;/td&gt;
&lt;td&gt;Degraded latency, silent errors&lt;/td&gt;
&lt;td&gt;Startup checks in prod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application metrics (Prometheus, logs)&lt;/td&gt;
&lt;td&gt;Errors per route, latencies, queues&lt;/td&gt;
&lt;td&gt;Problems outside the app's scope&lt;/td&gt;
&lt;td&gt;Real observability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External alerts (uptime monitors)&lt;/td&gt;
&lt;td&gt;Availability from outside the container&lt;/td&gt;
&lt;td&gt;Internal process state&lt;/td&gt;
&lt;td&gt;SLA and contracts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;When to trust the basic healthcheck:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;So Docker/Railway restarts the container if the process dies or goes zombie.&lt;/li&gt;
&lt;li&gt;To prevent a container that's still starting up from receiving traffic (with &lt;code&gt;--start-period&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;As a first-level triage tool in local development.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;When to go further:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the service has critical external dependencies (DB, caches, third-party APIs).&lt;/li&gt;
&lt;li&gt;If a silent failure has direct business cost.&lt;/li&gt;
&lt;li&gt;If the container keeps restarting but the problem persists because the error isn't process-level.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;A useful question before writing the endpoint&lt;/strong&gt;: ask what you want Docker to do if this check fails. If the answer is "restart the container," verify that restarting actually fixes that kind of failure. If the problem is a downed DB, restarting the container doesn't help — and it can trigger a restart loop that makes things worse.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common mistakes and gotchas the documentation doesn't warn you about
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;--start-period&lt;/code&gt; too short for heavy apps.&lt;/strong&gt;&lt;br&gt;
Apps with migrations, cache warmup, or slow initialization need a generous &lt;code&gt;--start-period&lt;/code&gt;. If the first checks fail before the app finishes starting up, Docker can mark the container &lt;code&gt;unhealthy&lt;/code&gt; before it's even ready. In Railway, that can translate into premature restarts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The &lt;code&gt;/health&lt;/code&gt; endpoint that does too much.&lt;/strong&gt;&lt;br&gt;
If the check connects to the DB, calls an external API, and verifies storage on every execution every 30 seconds, you're adding artificial load and creating a new failure point. A deep check can fail on timeout even when everything else is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;exit 1&lt;/code&gt; with no context in the logs.&lt;/strong&gt;&lt;br&gt;
When a healthcheck fails, Docker only records that it failed — not the command output. If you don't add your own logging inside the endpoint or the script, diagnosing why the check is failing in production becomes harder than it needs to be.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Better: wrapper script with logging before the exit&lt;/span&gt;
&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=10s --retries=3 \&lt;/span&gt;
  CMD /bin/sh -c 'curl -fsS http://localhost:3000/health &amp;gt; /tmp/hc.log 2&amp;gt;&amp;amp;1 || (cat /tmp/hc.log; exit 1)'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Confusing liveness with readiness.&lt;/strong&gt;&lt;br&gt;
Docker HEALTHCHECK is essentially a liveness check: is the process still alive? Kubernetes explicitly separates liveness from readiness. In standalone Docker or Railway, that distinction doesn't exist at the orchestrator level — so the responsibility falls on how you design the endpoint and when you expose it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Assuming "healthy" means the container is correctly receiving traffic.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;HEALTHCHECK&lt;/code&gt; affects the status reported by &lt;code&gt;docker ps&lt;/code&gt; and can influence restart policies, but it doesn't guarantee the load balancer (if there's one in front) is routing correctly. Those are separate layers.&lt;/p&gt;


&lt;h2&gt;
  
  
  FAQ: Docker healthcheck best practices
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What exit codes does HEALTHCHECK accept?&lt;/strong&gt;&lt;br&gt;
Only &lt;code&gt;0&lt;/code&gt; (healthy), &lt;code&gt;1&lt;/code&gt; (unhealthy), and &lt;code&gt;2&lt;/code&gt; (reserved, ignored). Any other code higher than that is treated as &lt;code&gt;1&lt;/code&gt;. The logic lives in the exit code of the command defined in &lt;code&gt;CMD&lt;/code&gt; — Docker doesn't read stdout or the HTTP response body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I include a database check in the healthcheck?&lt;/strong&gt;&lt;br&gt;
Depends on what you want Docker to do when it fails. If the DB goes down and restarting the container solves nothing, a DB ping in the healthcheck can generate restart loops with no resolution. A more sensible approach: verify the DB during app startup and fail there; use the healthcheck to confirm the process keeps responding after it's started.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I inspect the state of a running healthcheck?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# See state and logs of the last healthcheck&lt;/span&gt;
docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{{json .State.Health}}'&lt;/span&gt; &amp;lt;container_id&amp;gt; | jq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shows the log of the last attempt, the exit code, and the timestamp — useful information for diagnosing without guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the healthcheck affect &lt;code&gt;docker compose up&lt;/code&gt; behavior?&lt;/strong&gt;&lt;br&gt;
Yes, if you use &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;condition: service_healthy&lt;/code&gt;. In that case, the dependent service won't start until the check passes. That's useful, but it requires &lt;code&gt;--start-period&lt;/code&gt; to be well-calibrated; otherwise, compose can get stuck waiting indefinitely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Railway use the HEALTHCHECK from the Dockerfile?&lt;/strong&gt;&lt;br&gt;
Railway respects the &lt;code&gt;HEALTHCHECK&lt;/code&gt; defined in the Dockerfile to determine if a deploy was successful. If the check doesn't pass within the grace period, the deploy can get stuck in an error state. Worth checking the deploy logs if the service gets stuck in a loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When does it make sense NOT to define HEALTHCHECK?&lt;/strong&gt;&lt;br&gt;
In short-lived task containers (batch jobs, migration scripts), where the concept of "continuous health" doesn't apply. Also in local development where the overhead of configuring it doesn't add real value.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing: the signal is limited, and that's fine — as long as you know it
&lt;/h2&gt;

&lt;p&gt;A well-configured healthcheck is a basic safety net: it detects that the process died or went zombie, and gives the orchestrator a signal to act on. That's valuable. But if you build an entire service's operations on that single signal, you're promising yourself more than the mechanism can deliver.&lt;/p&gt;

&lt;p&gt;My stance is this: &lt;strong&gt;use HEALTHCHECK for what it does well — liveness — and build real observability for everything else&lt;/strong&gt;. Structured logs, application metrics, external alerts. Not because the healthcheck is bad, but because no tool should carry responsibilities that don't belong to it.&lt;/p&gt;

&lt;p&gt;The concrete next step: open the Dockerfile of any service you have running and ask yourself what happens if that endpoint returns 200 but the DB is down. If the answer is "I don't find out until someone reports an error," you have an observability gap that the healthcheck will never close.&lt;/p&gt;

&lt;p&gt;If you made it this far and you're thinking about how to evaluate dependencies before adding them to an operational stack, you might find what I wrote about &lt;a href="https://dev.to/blog/dependencias-npm-como-evaluar-libreria-antes-produccion"&gt;how to evaluate npm dependencies before pulling them into production&lt;/a&gt; useful. And if the topic of signals in systems resonates with you, the post on &lt;a href="https://juanchi.dev/en/blog/sniffnet-monitor-network-traffic-without-tcpdump" rel="noopener noreferrer"&gt;Sniffnet for monitoring network traffic&lt;/a&gt; follows a similar logic: tools with a clear scope, without overpromising.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Original source:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker HEALTHCHECK reference: &lt;a href="https://docs.docker.com/reference/dockerfile/#healthcheck" rel="noopener noreferrer"&gt;https://docs.docker.com/reference/dockerfile/#healthcheck&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://juanchi.dev/en/blog/docker-healthcheck-what-it-measures-decision-matrix" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>english</category>
      <category>docker</category>
      <category>devops</category>
      <category>railway</category>
    </item>
    <item>
      <title>Docker healthchecks: qué miden de verdad y qué no deberías prometer</title>
      <dc:creator>Juan Torchia</dc:creator>
      <pubDate>Fri, 03 Jul 2026 12:01:52 +0000</pubDate>
      <link>https://dev.to/jtorchia/docker-healthchecks-que-miden-de-verdad-y-que-no-deberias-prometer-490e</link>
      <guid>https://dev.to/jtorchia/docker-healthchecks-que-miden-de-verdad-y-que-no-deberias-prometer-490e</guid>
      <description>&lt;h1&gt;
  
  
  Docker healthchecks: qué miden de verdad y qué no deberías prometer
&lt;/h1&gt;

&lt;p&gt;¿Por qué tratamos a un healthcheck de Docker como si fuera un monitor de salud completo cuando la documentación oficial no promete nada de eso? Llevás meses corriendo contenedores, el panel de Railway muestra "healthy" en verde, y de alguna manera asumís que la app está bien. Esa suposición me incomoda. Y creo que vale la pena ponerla en crisis.&lt;/p&gt;

&lt;p&gt;Mi tesis es directa: &lt;strong&gt;un healthcheck que solo confirma que el proceso responde puede esconder fallas de negocio enteras&lt;/strong&gt;. El mecanismo hace lo que dice que hace — ni más, ni menos. El problema está en lo que le prometemos encima.&lt;/p&gt;




&lt;h2&gt;
  
  
  Docker healthcheck: qué dice la documentación y qué NO dice
&lt;/h2&gt;

&lt;p&gt;La &lt;a href="https://docs.docker.com/reference/dockerfile/#healthcheck" rel="noopener noreferrer"&gt;referencia oficial de HEALTHCHECK&lt;/a&gt; es clara en su alcance. La instrucción &lt;code&gt;HEALTHCHECK&lt;/code&gt; define un comando que Docker ejecuta periódicamente dentro del contenedor para saber si ese contenedor "funciona". Si el comando devuelve exit code &lt;code&gt;0&lt;/code&gt;, el contenedor es &lt;code&gt;healthy&lt;/code&gt;. Si devuelve &lt;code&gt;1&lt;/code&gt;, es &lt;code&gt;unhealthy&lt;/code&gt;. Si devuelve &lt;code&gt;2&lt;/code&gt;, se ignora (código reservado).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Ejemplo mínimo de la documentación oficial&lt;/span&gt;
&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=10s --start-period=15s --retries=3 \&lt;/span&gt;
  CMD curl -f http://localhost:3000/health || exit 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los parámetros disponibles son:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--interval&lt;/code&gt;: cada cuánto se ejecuta (default: 30s)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--timeout&lt;/code&gt;: tiempo máximo para que el comando responda (default: 30s)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--start-period&lt;/code&gt;: gracia inicial para que el contenedor arranque (default: 0s)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--retries&lt;/code&gt;: cuántos fallos consecutivos marcan &lt;code&gt;unhealthy&lt;/code&gt; (default: 3)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lo que la documentación &lt;strong&gt;no dice&lt;/strong&gt; es que ese endpoint &lt;code&gt;/health&lt;/code&gt; verifica el estado real de la aplicación. Eso es responsabilidad del desarrollador que lo implementa. Docker solo evalúa el exit code del comando — sin leer el body de la respuesta, sin interpretar JSON, sin saber si la base de datos está caída o si una queue acumula mensajes sin procesar.&lt;/p&gt;

&lt;p&gt;Lo incómodo: la mayoría de los ejemplos que circulan en tutoriales usan &lt;code&gt;curl -f&lt;/code&gt; contra un endpoint que devuelve &lt;code&gt;{"status":"ok"}&lt;/code&gt; sin conectar nada real. Es un healthcheck que solo confirma que el proceso de Node, Go o Java arrancó y acepta conexiones TCP. Eso tiene valor — pero es un valor muy limitado.&lt;/p&gt;




&lt;h2&gt;
  
  
  Dónde se equivoca la gente: la receta común y su costo oculto
&lt;/h2&gt;

&lt;p&gt;El patrón más frecuente que aparece en setups públicos de Docker es este:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Patrón común — mide TCP, no lógica de negocio&lt;/span&gt;
&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=5s --retries=3 \&lt;/span&gt;
  CMD curl -f http://localhost:8080/health || exit 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Y del lado de la app, el endpoint hace algo así:&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="c1"&gt;// Handler que solo confirma que el servidor responde&lt;/span&gt;
&lt;span class="nx"&gt;app&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;/health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El contenedor aparece como &lt;code&gt;healthy&lt;/code&gt;. El orquestador no lo reinicia. Railway muestra verde. Y mientras tanto, la conexión a PostgreSQL puede estar en timeout, el pool de conexiones agotado, o un worker crítico silenciosamente caído.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El costo oculto no es que el healthcheck mienta — es que le creemos más de lo que debería&lt;/strong&gt;. Cuando un equipo asume que "healthy = app sana", deja de mirar logs, métricas y señales más ricas. El healthcheck se convierte en el chivo expiatorio invisible: si el panel está verde, el problema tiene que estar en otro lado.&lt;/p&gt;

&lt;p&gt;Hay un contraejemplo que vale para cualquier sistema típico: imaginá un backend que procesa pagos. El endpoint &lt;code&gt;/health&lt;/code&gt; responde 200, el contenedor está &lt;code&gt;healthy&lt;/code&gt;, pero el cliente de la pasarela de pago tiene un token expirado. Cada transacción falla silenciosamente. Docker no sabe nada de eso — y no tendría por qué saberlo, si el endpoint no lo verifica.&lt;/p&gt;




&lt;h2&gt;
  
  
  Matriz de decisión: cuándo confiar en el healthcheck y cuándo ir más lejos
&lt;/h2&gt;

&lt;p&gt;Esta matriz no es absoluta — es un criterio prudente para decidir qué verificar y desde dónde:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Señal&lt;/th&gt;
&lt;th&gt;Qué detecta&lt;/th&gt;
&lt;th&gt;Qué NO detecta&lt;/th&gt;
&lt;th&gt;Herramienta más adecuada&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;HEALTHCHECK&lt;/code&gt; básico (curl TCP)&lt;/td&gt;
&lt;td&gt;Proceso arrancado, puerto abierto&lt;/td&gt;
&lt;td&gt;Fallas de DB, lógica rota, queues&lt;/td&gt;
&lt;td&gt;Primer nivel de triaje&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint &lt;code&gt;/health&lt;/code&gt; con DB ping&lt;/td&gt;
&lt;td&gt;Conectividad a base de datos&lt;/td&gt;
&lt;td&gt;Datos corruptos, permisos, migraciones&lt;/td&gt;
&lt;td&gt;Readiness básico&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Endpoint &lt;code&gt;/health&lt;/code&gt; profundo&lt;/td&gt;
&lt;td&gt;Dependencias críticas respondiendo&lt;/td&gt;
&lt;td&gt;Latencia degradada, errores silenciosos&lt;/td&gt;
&lt;td&gt;Startup checks en prod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Métricas de aplicación (Prometheus, logs)&lt;/td&gt;
&lt;td&gt;Errores por ruta, latencias, colas&lt;/td&gt;
&lt;td&gt;Problemas fuera del scope de la app&lt;/td&gt;
&lt;td&gt;Observabilidad real&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alertas externas (uptime monitors)&lt;/td&gt;
&lt;td&gt;Disponibilidad desde fuera del contenedor&lt;/td&gt;
&lt;td&gt;Estado interno del proceso&lt;/td&gt;
&lt;td&gt;SLA y contratos&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Cuándo confiar en el healthcheck básico:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Para que Docker/Railway reinicie el contenedor si el proceso muere o queda zombi.&lt;/li&gt;
&lt;li&gt;Para evitar que un contenedor que todavía está arrancando reciba tráfico (con &lt;code&gt;--start-period&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Como primer nivel de triaje en desarrollo local.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cuándo ir más lejos:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Si el servicio tiene dependencias externas críticas (DB, caches, APIs de terceros).&lt;/li&gt;
&lt;li&gt;Si una falla silenciosa tiene costo de negocio directo.&lt;/li&gt;
&lt;li&gt;Si el contenedor se reinicia pero el problema persiste porque el error no es de proceso.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Un criterio útil antes de escribir el endpoint&lt;/strong&gt;: preguntá qué querés que haga Docker si este check falla. Si la respuesta es "reiniciar el contenedor", verificá que reiniciar realmente solucione ese tipo de falla. Si el problema es una DB caída, reiniciar el contenedor no ayuda — y puede generar un loop de reinicios que agrava la situación.&lt;/p&gt;




&lt;h2&gt;
  
  
  Errores comunes y gotchas que la documentación no anticipa
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;--start-period&lt;/code&gt; demasiado corto para apps pesadas.&lt;/strong&gt;&lt;br&gt;
Apps con migraciones, warmup de caché o inicialización lenta necesitan un &lt;code&gt;--start-period&lt;/code&gt; generoso. Si los primeros checks fallan antes de que la app termine de arrancar, Docker puede marcar el contenedor como &lt;code&gt;unhealthy&lt;/code&gt; antes de que esté listo. En Railway, eso puede traducirse en reinicios prematuros.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. El endpoint &lt;code&gt;/health&lt;/code&gt; que hace demasiado.&lt;/strong&gt;&lt;br&gt;
Si el check conecta a la DB, llama a una API externa y verifica storage en cada ejecución cada 30 segundos, estás agregando carga artificial y creando un punto de falla nuevo. Un check profundo puede fallar por timeout aunque todo lo demás esté bien.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;exit 1&lt;/code&gt; sin contexto en los logs.&lt;/strong&gt;&lt;br&gt;
Cuando el healthcheck falla, Docker solo registra que falló — no el output del comando. Si no agregás logging propio dentro del endpoint o del script, diagnosticar por qué el check falla en producción se vuelve más difícil de lo necesario.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Mejor: script wrapper con logging antes del exit&lt;/span&gt;
&lt;span class="k"&gt;HEALTHCHECK&lt;/span&gt;&lt;span class="s"&gt; --interval=30s --timeout=10s --retries=3 \&lt;/span&gt;
  CMD /bin/sh -c 'curl -fsS http://localhost:3000/health &amp;gt; /tmp/hc.log 2&amp;gt;&amp;amp;1 || (cat /tmp/hc.log; exit 1)'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Confundir liveness con readiness.&lt;/strong&gt;&lt;br&gt;
Docker HEALTHCHECK es básicamente un liveness check: ¿el proceso sigue vivo? Kubernetes separa liveness de readiness de manera explícita. En Docker standalone o en Railway, esa distinción no existe a nivel de orquestador — así que la responsabilidad cae en cómo diseñás el endpoint y cuándo lo exponés.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Asumir que "healthy" implica que el contenedor recibe tráfico correctamente.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;HEALTHCHECK&lt;/code&gt; afecta el estado reportado por &lt;code&gt;docker ps&lt;/code&gt; y puede influir en políticas de reinicio, pero no garantiza que el load balancer (si hay uno por delante) esté enrutando bien. Son capas distintas.&lt;/p&gt;


&lt;h2&gt;
  
  
  FAQ: Docker healthcheck buenas prácticas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;¿Qué exit codes acepta HEALTHCHECK?&lt;/strong&gt;&lt;br&gt;
Solo &lt;code&gt;0&lt;/code&gt; (healthy), &lt;code&gt;1&lt;/code&gt; (unhealthy) y &lt;code&gt;2&lt;/code&gt; (reserved, ignorado). Cualquier otro código mayor se trata como &lt;code&gt;1&lt;/code&gt;. La lógica está en el exit code del comando definido en &lt;code&gt;CMD&lt;/code&gt; — Docker no lee el stdout ni el body HTTP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Debería incluir un check de base de datos en el healthcheck?&lt;/strong&gt;&lt;br&gt;
Depende de qué querés que haga Docker cuando falle. Si la DB cae y reiniciar el contenedor no soluciona nada, un DB ping en el healthcheck puede generar loops de reinicio sin resolución. Una opción más prudente: verificar la DB en el startup de la app y fallar ahí; usar el healthcheck para confirmar que el proceso sigue respondiendo después de arrancar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Cómo inspecciono el estado de un healthcheck en ejecución?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Ver estado y logs del último healthcheck&lt;/span&gt;
docker inspect &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{{json .State.Health}}'&lt;/span&gt; &amp;lt;container_id&amp;gt; | jq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esto muestra el log del último intento, el exit code y el timestamp — información útil para diagnosticar sin adivinar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿El healthcheck afecta el comportamiento de &lt;code&gt;docker compose up&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
Sí, si usás &lt;code&gt;depends_on&lt;/code&gt; con &lt;code&gt;condition: service_healthy&lt;/code&gt;. En ese caso, el servicio dependiente no arranca hasta que el check pase. Eso es útil, pero requiere que &lt;code&gt;--start-period&lt;/code&gt; esté bien calibrado; si no, el compose puede quedarse esperando indefinidamente.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Railway usa el HEALTHCHECK del Dockerfile?&lt;/strong&gt;&lt;br&gt;
Railway respeta el &lt;code&gt;HEALTHCHECK&lt;/code&gt; definido en el Dockerfile para determinar si el deploy fue exitoso. Si el check no pasa dentro del período de gracia, el deploy puede quedar en estado de error. Vale revisar los logs del deploy si el servicio queda en loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;¿Cuándo tiene sentido no definir HEALTHCHECK?&lt;/strong&gt;&lt;br&gt;
En contenedores de tareas cortas (batch jobs, scripts de migración), donde el concepto de "salud continua" no aplica. También en desarrollo local donde el overhead de configurarlo no aporta valor real.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cierre: la señal es limitada, y eso está bien — si lo sabés
&lt;/h2&gt;

&lt;p&gt;Un healthcheck bien configurado es una red de seguridad básica: detecta que el proceso murió o quedó zombi, y le da al orquestador una señal para actuar. Eso es valioso. Pero si construís toda la operación de un servicio sobre esa única señal, estás prometiéndote más de lo que el mecanismo puede darte.&lt;/p&gt;

&lt;p&gt;Mi postura es esta: &lt;strong&gt;usá HEALTHCHECK para lo que hace bien — liveness — y construí observabilidad real para todo lo demás&lt;/strong&gt;. Logs estructurados, métricas de aplicación, alertas externas. No porque el healthcheck sea malo, sino porque ninguna herramienta debería cargar con responsabilidades que no le corresponden.&lt;/p&gt;

&lt;p&gt;El próximo paso concreto: abrí el Dockerfile de cualquier servicio que tengas corriendo y preguntate qué pasa si ese endpoint devuelve 200 pero la DB está caída. Si la respuesta es "no me entero hasta que alguien reporta un error", tenés un gap de observabilidad que el healthcheck nunca va a cerrar.&lt;/p&gt;

&lt;p&gt;Si llegaste hasta acá y estás pensando en cómo evaluar las dependencias de un proyecto antes de meterlas en un stack operativo, puede interesarte lo que escribí sobre &lt;a href="https://dev.to/blog/dependencias-npm-como-evaluar-libreria-antes-produccion"&gt;cómo evaluar dependencias npm antes de incorporarlas&lt;/a&gt;. Y si el tema de señales en sistemas te llama la atención, el post sobre &lt;a href="https://juanchi.dev/es/blog/sniffnet-monitor-trafico-red-ui-accesible-rust" rel="noopener noreferrer"&gt;Sniffnet para monitorear tráfico de red&lt;/a&gt; tiene una lógica parecida: herramientas con alcance claro, sin sobreprometer.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Fuente original:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker HEALTHCHECK reference: &lt;a href="https://docs.docker.com/reference/dockerfile/#healthcheck" rel="noopener noreferrer"&gt;https://docs.docker.com/reference/dockerfile/#healthcheck&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Este artículo fue publicado originalmente en &lt;a href="https://juanchi.dev/es/blog/docker-healthcheck-buenas-practicas-que-miden-y-que-no" rel="noopener noreferrer"&gt;juanchi.dev&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>espanol</category>
      <category>docker</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
