El problema: la caché local no viaja
La caché local ya elimina el trabajo redundante dentro de una máquina, pero vive en un solo disco. En cuanto el trabajo cruza a otra persona o a un job de CI, la caché deja de existir: el mismo paquete intacto se recompila una vez por cada desarrollador y una vez por cada job. La redundancia que la caché local mató en el tiempo vuelve, multiplicada, en el espacio.
En el nivel anterior aprendiste que un monorepo con caché local ya no repite trabajo: ejecutas una tarea una vez, su salida queda guardada bajo el hash de sus entradas, y cualquier repetición idéntica se salta el cómputo. Pero esa caché vive en un disco. Contesta a la pregunta “¿ya lo hice yo?”, nunca a “¿ya lo hizo alguien?”. En cuanto el trabajo cruza a otra máquina —la de un compañero, un runner de CI recién nacido— la caché no viaja con él, y el mismo paquete que nadie tocó se vuelve a compilar desde cero. La redundancia que eliminaste en el eje del tiempo reaparece, intacta y multiplicada, en el eje del espacio.
- Distinguir la redundancia en el tiempo, que la caché local elimina, de la redundancia en el espacio, que no toca.
- Entender por qué CI, con runners efímeros, es el peor caso y recompila justo lo que nadie cambió.
- Ver por qué cachear el directorio
.turbocomo artefacto de CI es un parche frágil. - Nombrar la pieza que falta: una caché compartida y direccionada por contenido.
La caché local ya existe, pero vive en un disco
Recuerda el mecanismo. Turborepo o Nx toman las entradas de una tarea —los archivos fuente del paquete, sus dependencias resueltas, la configuración de la tarea, las variables de entorno declaradas— y las resumen en un hash. La primera vez, ejecutan la tarea y guardan su salida bajo ese hash en .turbo/cache. La segunda vez, si nada cambió, el hash coincide, y en lugar de recompilar restauran la salida guardada en milisegundos. Es memoización pura: la tarea se trata como una función de sus entradas, y una función con las mismas entradas no hace falta evaluarla dos veces.
Conviene apreciar lo potente que es esa idea antes de señalar su límite. Convierte el build de un proceso que siempre se ejecuta en uno que se ejecuta solo cuando algo cambió; el trabajo deja de ser proporcional al número de veces que pides construir y pasa a ser proporcional al número de veces que algo es realmente distinto. Para una sola persona en una sola máquina, eso ya es transformador: la mayoría de tus builds dejan, sin más, de compilar nada.
El problema no está en la idea, que es correcta y poderosa, sino en dónde vive el resultado. Ese .turbo/cache es un directorio en tu sistema de archivos, atado a tu máquina. Es una memoria privada. Responde con exactitud a la pregunta “¿ya ejecuté yo esta tarea con estas entradas?”, y esa pregunta solo tiene sentido dentro de los límites de tu disco. El instante en que el mismo cómputo —las mismas entradas, el mismo hash, la misma salida garantizada— se necesita en otra máquina, tu caché es irrelevante, porque la otra máquina no puede leerla.
La caché local ataca la redundancia temporal: tú, en tu máquina, ejecutando la misma tarea muchas veces a lo largo del día. Pero hay una segunda redundancia, la espacial: muchas máquinas distintas ejecutando la misma tarea idéntica, cada una por primera vez. La caché local no la roza. Y esta segunda es la que crece con tu organización: cada persona que se une, cada job de CI que añades, es otra copia del mismo trabajo intacto que nadie comparte.
La distinción entre ambos ejes no es retórica, porque tienen escalas distintas y se combaten con mecanismos distintos. La redundancia temporal está acotada por lo que una sola persona puede repetir en una jornada; la espacial no tiene techo, porque crece con cada máquina y cada ejecución que sumas al sistema. Optimizar la primera y creer que has terminado es el error conceptual que este nivel entero viene a corregir.
| Eje | Qué repite | Cómo escala | Qué lo elimina |
|---|---|---|---|
| Temporal | tus corridas repetidas en un disco | con tus horas de trabajo | caché local |
| Espacial | máquinas y jobs distintos, cada uno desde cero | con el tamaño del equipo y del CI | caché remota |
CI: recompilar lo intacto, en cada push, por diseño
El caso más flagrante es la integración continua. Un runner de CI es, por diseño, efímero y sin estado: cada ejecución arranca un contenedor limpio, con un disco vacío, hace su trabajo y se destruye. Esa pureza es una virtud —garantiza builds reproducibles, sin contaminación entre ejecuciones— pero tiene un corolario brutal para la caché: el runner nace siempre con la caché local fría. No hay .turbo/cache que restaurar porque el disco acaba de crearse.
La consecuencia es que CI recompila todo, en cada push, incluido el noventa por ciento del repositorio que nadie tocó en ese cambio. Modificaste una línea en un paquete hoja, pero el pipeline reconstruye, retesta y relintea decenas de paquetes cuyas entradas son byte por byte idénticas a las de ayer. El trabajo es redundante en el sentido más literal: mismas entradas, misma salida garantizada, calculada una y otra vez porque ninguna ejecución puede ver lo que hizo la anterior.
flowchart TB subgraph maqA[Maquina A] a1[compila ui] a2[compila utils] end subgraph maqB[Maquina B] b1[compila ui] b2[compila utils] end subgraph ci[Job de CI efimero] c1[compila ui] c2[compila utils] end fuente[Mismo codigo intacto] --> maqA fuente --> maqB fuente --> ci style fuente fill:#89b4fa,color:#11111b style ci fill:#f38ba8,color:#11111b
Y ni siquiera dentro de un mismo pipeline se comparte. Si paralelizas CI en varios shards para ir más rápido, cada shard es otra máquina con otro disco vacío: el shard que compila ui no le sirve de nada al shard que también necesita ui, porque no hay memoria común entre ellos. Has multiplicado las máquinas para ganar paralelismo y, con ello, has multiplicado también las veces que se recompila lo mismo.
Suele objetarse que CI ya cachea algo: el directorio node_modules, por ejemplo, se restaura entre ejecuciones para no reinstalar dependencias en cada corrida. Es cierto, pero esa caché resuelve la instalación, no la construcción, y son problemas distintos. Cachear las dependencias evita volver a descargarlas y enlazarlas; no evita volver a compilar, testear y lintear los paquetes cuyo código no cambió una coma. La salida de las tareas —el dist/, el resultado de los tests, el informe del linter— es exactamente lo que la caché local sabe guardar y lo que CI, sin una caché compartida, tira a la basura cada vez que destruye el runner. Confundir la caché de dependencias con la caché de tareas es creer que el problema está resuelto cuando solo se ha tocado su mitad más barata.
La ironía es que cuanto más creces, peor se pone. Un proyecto de una persona apenas nota la redundancia de CI; uno de cincuenta, con decenas de PRs abiertos a la vez y CI corriendo en cada push de cada uno, multiplica la misma recompilación intacta por un factor enorme. El coste no crece con el tamaño del código, crece con la actividad del equipo sobre ese código, que es justo la dimensión en la que quieres poder escalar sin castigo.
Disco local
La caché vive en .turbo/cache, atada a un sistema de archivos. Contesta “¿lo hice yo?”, nunca “¿lo hizo alguien?”.
Runner efímero
Cada job de CI nace con disco vacío. La caché local siempre arranca fría, así que se recompila todo en cada push.
Shards aislados
Paralelizar CI crea más máquinas sin memoria común. El trabajo de un shard no llega al de al lado.
El parche que no escala y la pieza que falta
La tentación evidente es guardar el directorio .turbo como artefacto de caché de CI entre ejecuciones, con una clave derivada del hash del lockfile. Ayuda algo —una ejecución de CI puede heredar la caché de la anterior en la misma rama— pero es un parche frágil por varias razones a la vez. La clave de la caché de artefactos es tosca, así que un cambio menor la invalida entera. No cruza bien entre ramas ni entre PRs, que es justo donde más se repite trabajo. Guarda un único blob grande en lugar de aprovechar la granularidad por tarea. Y, sobre todo, nunca llega a las máquinas de las personas: el desarrollador que hace pull esta tarde seguirá recompilando lo que CI ya construyó esta mañana.
El parche típico tiene esta forma, y su fragilidad se lee en la propia clave:
# El parche fragil: cachear .turbo como artefacto bajo una clave tosca
- uses: actions/cache@v4
with:
path: .turbo
key: turbo-${{ hashFiles('pnpm-lock.yaml') }}
Basta que cambie el lockfile para invalidar la caché entera, aunque el noventa por ciento de las tareas siguiera siendo válido, porque la clave no distingue tarea por tarea: guarda y restaura un único blob monolítico. Y esa caché, por definición, nace y muere dentro del contexto de CI; jamás sale de ahí para llegar a la máquina de un desarrollador. Es una optimización de la infraestructura de CI disfrazada de memoria compartida.
La raíz de la fragilidad es que estos parches confunden transporte con direccionamiento. Guardar un blob entre jobs es transportar bytes de una ejecución a la siguiente; lo que hace falta es direccionar por contenido, de modo que cualquier máquina pueda preguntar por el hash exacto de una tarea concreta y obtener justo su salida, sin heredar un paquete monolítico atado a una clave gruesa. La diferencia entre ambas cosas es la que hay entre copiar una caja entera y consultar un índice.
Cachear .turbo como artefacto puede acelerar de un job de CI al siguiente dentro de la misma rama, pero deja fuera los dos escenarios donde más se desperdicia: el trabajo idéntico entre PRs distintos y el que se repite entre CI y las máquinas del equipo. Es una optimización local a la infraestructura de CI, no una memoria compartida de verdad. Confundir una con otra lleva a creer que el problema está resuelto cuando apenas se ha rozado.
Lo que falta, entonces, no es una caché mejor: es una caché en otro sitio. Un almacén al que cualquier máquina pueda preguntar “¿existe ya la salida para este hash de entradas?” y, si existe, descargarla en lugar de calcularla. La clave del asunto es que el hash de las entradas es el mismo en todas partes: si tu máquina y el runner de CI parten del mismo código, resuelven las mismas dependencias y declaran las mismas variables, producen idénticamente el mismo hash. Ese hash puede ser una dirección global, no local. Y una caché indexada por esa dirección deja de ser tu memoria privada para ser la memoria del equipo entero. Eso es el remote caching, y el resto de este nivel es cómo funciona, cómo se conecta y qué transforma.
Caché local
Rápida pero privada. Solo responde “¿lo hice yo en este disco?”. No cruza a nadie más.
El parche de CI
Un blob atado a una clave gruesa. Ayuda de un job al siguiente, nunca a las personas ni entre ramas.
Caché remota
Direccionada por contenido. Cualquier máquina pregunta por el hash y obtiene justo esa salida. Es la pieza que falta.
La factura invisible
El desperdicio de la redundancia espacial es fácil de ignorar porque no llega en una única factura escandalosa, sino repartido en mil pagos pequeños que nadie suma. Cada desarrollador que espera tres minutos a que compile lo que su compañera ya compiló; cada job de CI que factura minutos de runner por reconstruir lo intacto; cada shard que duplica el trabajo del de al lado. Ninguno de esos costes duele por separado, y precisamente por eso se toleran de forma indefinida.
Pero se pueden sumar, y el resultado sorprende. Multiplica el tiempo de una build en frío por el número de veces que ocurre al día —una por push, por cada persona, por cada rama— y por los días del mes, y obtendrás una cifra de horas de cómputo y de espera humana que casi siempre justifica con creces el esfuerzo de compartir la caché. La redundancia espacial no es un problema estético de ingeniería: es una línea real en la factura de tu proveedor de CI y en el tiempo muerto de tu equipo.
Hay además un coste que no aparece en ninguna factura y es el más caro de todos: el coste de contexto. Un pipeline de veinte minutos rompe el flujo de trabajo; obliga a cambiar de tarea, a perder el hilo, a volver más tarde a recoger el resultado. Acortar ese ciclo a segundos no solo ahorra minutos de máquina, devuelve la continuidad de pensamiento, que es el recurso más escaso de un equipo que construye software.
Nada de esto es hipotético para quien haya trabajado en un monorepo grande sin caché compartida: es la experiencia cotidiana de esperar a que CI reconstruya, por enésima vez, exactamente lo mismo que ya reconstruyó en el PR de al lado. Poner cifras a ese hastío es lo que transforma una queja de pasillo en un caso de negocio, y el caso de negocio del remote caching resulta, casi siempre, abrumador.
Los costes que llegan en un único cargo grande se combaten; los que llegan goteando se normalizan. La redundancia espacial pertenece al segundo tipo: nadie decide nunca “voy a desperdiciar mil horas de CI al mes”, sino que se llega ahí por acumulación de esperas de tres minutos que parecían inocentes. Ponerle un número, aunque sea aproximado, es el primer acto de ingeniería, porque convierte una molestia difusa en un problema medible con una solución concreta.
La caché local resuelve un problema real pero pequeño: que tú, en tu máquina, no repitas a lo largo del día un trabajo que ya hiciste. Es una optimización sobre el eje del tiempo, acotada a un disco. El problema que de verdad crece con un producto no vive en ese eje. Vive en el espacio: en las decenas de máquinas de un equipo y los cientos de jobs de CI que, cada uno por separado y cada uno partiendo de cero, ejecutan exactamente el mismo cómputo sobre exactamente las mismas entradas. Ese desperdicio no es un fallo de configuración que puedas afinar; es el estado por defecto de una arquitectura sin estado. Un runner efímero está diseñado para no recordar nada, y no recordar nada significa, inevitablemente, rehacerlo todo. La única forma de vencer un coste que escala como el producto del tamaño del equipo por la cantidad de trabajo intacto es dejar de tratar la caché como una propiedad de la máquina y empezar a tratarla como una propiedad del contenido: si dos cómputos tienen las mismas entradas, tienen la misma dirección, sin importar quién los ejecute ni cuándo. Direccionar por contenido convierte “compilar” en “consultar si esta salida exacta ya existe en alguna parte”, y esa consulta, a diferencia de la compilación, sí puede compartirse entre todo el que comparta el contenido. Todo el remote caching es el desarrollo riguroso de esa única idea: la memoria del equipo no puede vivir en ningún disco, tiene que vivir en el hash.
- Cronometra una build en frío de tu CI, con la caché vacía. Esa cifra es cuánto trabajo se rehace en cada push desde cero.
- Revisa los últimos diez PRs de tu repo y cuenta cuántos paquetes cambiaron de verdad frente al total. La diferencia es la redundancia espacial en bruto.
- Localiza
.turbo/cacheen tu máquina y comprueba que es un directorio local: no hay forma de que otra máquina lo lea. - Estima el desperdicio semanal: tareas intactas por número de desarrolladores por número de ejecuciones de CI. Ese producto es lo que estás pagando.
- Explica en una frase por qué ninguna mejora de la caché local, por buena que sea, puede reducir esa cifra.