wandres.dev
CANCELACIÓN · debounce y throttle

Por qué cancelar: la respuesta obsoleta que pisa tus datos

La cancelación no es una optimización de recursos sino un requisito de corrección. Esta lección reconstruye la condición de carrera clásica del buscador: el usuario teclea cinco letras, salen cinco peticiones, la red las responde en un orden que nadie garantiza y el reducer escribe en el estado la última que llegó, no la última que se pidió. Analizamos por qué el orden de salida y el de llegada son independientes, por qué el reducer es correcto acción a acción y aun así el resultado global es falso, qué dejan sin resolver los parches habituales —comparar la consulta al recibir, numerar peticiones, ignorar respuestas viejas— y por qué la política correcta no es filtrar el resultado obsoleto sino impedir que llegue a existir: matar el efecto en el instante en que deja de ser relevante.

⏱ 16 min

Un usuario teclea swift en tu buscador. Cinco pulsaciones, cinco acciones, cinco efectos, cinco peticiones a la red. La cuarta petición —la de swif— pasa por un servidor congestionado y tarda 900 ms; la quinta —la de swift— cae en caché y vuelve en 40. En pantalla aparecen los resultados correctos durante medio segundo y luego, sin que nadie toque nada, se sustituyen por los de una consulta que el usuario ya abandonó. Ningún reducer se equivocó: cada acción hizo exactamente lo que decía hacer. Lo que falló es que el sistema no tenía forma de saber que uno de esos efectos había dejado de importar. Este nivel entero trata de esa forma, y esta lección empieza por lo único que justifica el resto: entender con precisión qué se rompe cuando un efecto sobrevive a su propia relevancia.

🎯 Al terminar esta lección sabrás
  • Reconstruir la condición de carrera del buscador y localizar el punto exacto donde el estado se corrompe.
  • Distinguir el orden en que se lanzan los efectos del orden en que sus resultados vuelven al store.
  • Evaluar los parches habituales —comparar la consulta al recibir, numerar las peticiones— y ver qué siguen sin resolver.
  • Formular la cancelación como política de corrección: un efecto obsoleto no se filtra a la salida, se detiene en origen.

La carrera clásica del buscador

El código culpable es tan corriente que resulta difícil verlo como sospechoso. Una acción por cada cambio de texto, un efecto por cada acción, y una acción de vuelta con los resultados.

case let .consultaCambiada(texto):
  state.consulta = texto
  return .run { send in
    let items = try await cliente.buscar(texto)
    await send(.resultadosRecibidos(items))
  }

case let .resultadosRecibidos(items):
  state.resultados = items
  return .none

Lee resultadosRecibidos con lupa: escribe en state.resultados cualquier lista que le llegue, sin preguntar de dónde viene. Y no puede preguntarlo, porque en la acción no viaja esa información: [Item] no dice a qué consulta pertenece. El reducer, que es puro y determinista, hace lo correcto con lo que tiene. El defecto no está en su lógica sino en el vocabulario del dominio, que ha perdido por el camino el vínculo entre una pregunta y su respuesta.

sequenceDiagram
participant U as Usuario
participant S as Store
participant N as Red
U->>S: teclea swif
S->>N: buscar swif
U->>S: teclea swift
S->>N: buscar swift
N-->>S: resultados de swift en 40 ms
S->>S: escribe resultados correctos
N-->>S: resultados de swif en 900 ms
S->>S: sobrescribe con datos obsoletos

El diagrama deja al descubierto la asimetría: las peticiones salen en el orden de las pulsaciones, pero las respuestas vuelven en el orden que decida la red. El store aplica ambas con la misma obediencia, y como la escritura es destructiva, gana la última en llegar. La pantalla acaba mostrando la respuesta a una pregunta que el usuario ya no está haciendo.

Conviene además desconfiar del ejemplo del buscador, porque su fama hace pensar que el problema es de los buscadores. La forma del defecto es mucho más general: aparece siempre que una acción del usuario puede repetirse antes de que se resuelva la anterior y ambas escriben en el mismo sitio del estado. Un gesto de arrastre para refrescar que el usuario repite impaciente; un selector de filtros que dispara una recarga por cada toque; una pantalla de detalle que se abre, se cierra y se vuelve a abrir con otro identificador; una paginación que carga la página tres mientras la dos sigue en camino. En todos los casos hay una secuencia de intenciones y un único hueco donde escribir el resultado, y en todos, si no se hace nada, gana la respuesta más lenta en lugar de la intención más reciente.

El orden de salida no es el orden de llegada

Conviene resistir la tentación de tratar esto como mala suerte estadística. No hay ninguna capa del sistema que prometa que dos tareas independientes terminen en el orden en que empezaron, y hay muchas razones para que no lo hagan: rutas distintas, reintentos de TCP, multiplexación de HTTP/2 donde un flujo lento no bloquea a otro, cachés que responden algunas consultas al instante y otras no, o simplemente un servidor cuyo coste de búsqueda depende del término. Con concurrencia estructurada de Swift la situación es aún más nítida: cada .run es una Task propia, corren en paralelo sobre el pool cooperativo, y el runtime no impone ninguna relación de orden entre ellas.

Lo interesante es que el fallo no es intermitente por naturaleza, sino sensible a la latencia: aparece siempre que la diferencia de tiempos entre dos peticiones supera el intervalo entre dos pulsaciones. En tu máquina, contra un servidor local, esa condición casi nunca se cumple y el bug es invisible. En una red móvil, con un usuario que escribe rápido, se cumple varias veces por sesión. Es el peor perfil posible para un defecto: indetectable donde se desarrolla y frecuente donde se usa.

Hay algo más de fondo, y es lo que convierte esto en un problema de arquitectura y no de red. El reducer aplica una secuencia de acciones a un estado; su corrección se define acción a acción. Pero cuál es la última escritura válida es una propiedad del historial completo, no de un paso aislado. Cuando el orden de llegada deja de coincidir con el orden de intención, el sistema pierde la única referencia que tenía para decidir qué escritura debe prevalecer, y ninguna cantidad de pureza en el reducer la recupera.

⚠️
El indicador de carga miente igual que los datos

La misma carrera arruina el state.cargando. Si cada efecto lo pone a verdadero al salir y a falso al volver, la respuesta rápida lo apaga mientras la lenta sigue viva, y la lenta lo vuelve a apagar cuando ya no había nada cargando. El spinner parpadea, desaparece con peticiones en vuelo y a veces se queda encendido para siempre si un efecto falla en silencio. Los indicadores de progreso son estado derivado del conjunto de efectos activos; si no controlas ese conjunto, no puedes describirlo con un booleano honesto.

Los parches que casi funcionan

Ante el síntoma, la reacción instintiva es filtrar a la salida: dejar que todas las respuestas lleguen y descartar las que ya no interesan. La versión más común compara la consulta al recibir, para lo cual hay que hacerla viajar dentro de la acción.

case let .resultadosRecibidos(texto, items):
  guard texto == state.consulta else { return .none }
  state.resultados = items
  return .none

Funciona, y por eso es peligroso: resuelve el caso visible y deja intactos los demás.

📡

El trabajo ya se hizo

Filtrar a la salida no evita la petición: se envió, viajó, ocupó una conexión que la consulta buena podría haber usado y el servidor la calculó entera. En una red móvil eso es latencia añadida a la respuesta que sí importa.

💸

Los efectos colaterales no se filtran

Si el efecto además escribía en caché, refrescaba un token o registraba analítica, esas consecuencias ocurrieron. El guard protege una variable del estado, no el mundo.

🕳️

El filtro tiene agujeros

Si el usuario borra una letra y la vuelve a escribir, la consulta antigua coincide otra vez con la actual y su respuesta obsoleta pasa el control como si fuera fresca.

🧬

Contamina el dominio

Para comparar hay que hacer viajar la consulta —o un número de secuencia— dentro de la acción. El Action, que debería describir lo que pasó, empieza a describir fontanería de concurrencia.

De las cuatro objeciones, la tercera es la que convierte el parche en una trampa. Un filtro que falla a veces es peor que ninguno, porque genera confianza: el equipo da el bug por cerrado, el caso raro se reporta meses después como algo distinto y nadie relaciona ambos informes. Los remedios parciales en concurrencia tienen esa firma característica —reducen la frecuencia del síntoma sin eliminar la causa— y esa reducción de frecuencia es exactamente lo que impide que el defecto se vuelva a reproducir en condiciones controladas.

El parche siguiente sustituye la comparación por un contador monótono, que sí cierra el agujero del texto repetido.

case let .consultaCambiada(texto):
  state.consulta = texto
  state.secuencia += 1
  return .run { [texto, n = state.secuencia] send in
    await send(.respuesta(n, try await cliente.buscar(texto)))
  }

case let .respuesta(n, items):
  guard n == state.secuencia else { return .none }
  state.resultados = items
  return .none

Es correcto, y merece la pena mirar por qué lo es: acabas de reimplementar a mano, dentro de tu dominio, una noción de identidad y vigencia de un efecto. El campo secuencia no describe nada del negocio —no es un dato que ningún diseñador pidiera, ni que ninguna pantalla muestre—, es contabilidad de concurrencia disfrazada de estado. Eso ya existe en la arquitectura, está probado, funciona igual para búsquedas que para temporizadores y sockets, y no obliga a contaminar cada Action y cada State con metadatos de fontanería. Cuando un patrón te empuja a inventar tu propio sistema de versiones, casi siempre es señal de que el mecanismo correcto vive un nivel más abajo.

Cancelar es corrección, no ahorro

La política correcta invierte el planteamiento. En vez de dejar correr el efecto obsoleto y filtrar su resultado, se le impide producirlo: en el instante en que la nueva consulta llega, el efecto anterior se detiene. Nadie tiene que decidir después si su respuesta era vigente, porque no habrá respuesta.

private enum CancelID { case busqueda }

case let .consultaCambiada(texto):
  state.consulta = texto
  return .run { send in
    await send(.resultadosRecibidos(try await cliente.buscar(texto)))
  }
  .cancellable(id: CancelID.busqueda, cancelInFlight: true)

Ese cancelInFlight: true —que las lecciones 2 y 3 desarman pieza a pieza— establece un invariante fuerte: como mucho hay un efecto de búsqueda vivo en todo momento. Con ese invariante, resultadosRecibidos recupera su inocencia; la única respuesta que puede llegar es la de la consulta en curso, y escribirla sin preguntar vuelve a ser correcto. Fíjate en la forma del razonamiento, porque se repetirá todo el nivel: no hemos añadido una comprobación, hemos eliminado el estado del mundo que la hacía necesaria.

Merece la pena insistir en la palabra corrección, porque el discurso habitual sobre cancelación la vende como una optimización —ahorra red, ahorra batería, ahorra dinero de servidor— y esa venta se lee como algo deseable pero postergable. No lo es. Un buscador sin cancelación no es un buscador eficiente que gasta de más; es un buscador que muestra datos falsos, de forma intermitente, con más frecuencia cuanto peor sea la red del usuario. El ahorro de recursos es un efecto secundario agradable de haber arreglado un defecto de exactitud, y ordenar así las prioridades cambia cómo se prioriza el trabajo: esto no compite con las mejoras de rendimiento del sprint, compite con los bugs.

ℹ️
El caso más raro es también el más frecuente en producción

La probabilidad de la carrera crece con la latencia y con la velocidad de escritura del usuario, dos variables que en desarrollo están en su mínimo y en producción en su máximo. Añade a eso que las respuestas obsoletas suelen ser plausibles —resultados reales, solo que de otra consulta— y tienes la receta de un defecto que jamás verás en tu máquina y que tus usuarios describirán con frases inútiles del tipo a veces se queda pillado o me sale otra cosa. Cuando un informe de bug es vago y no reproducible, la asincronía sin control es siempre la primera sospechosa.

La cancelación es lo que convierte un efecto en un valor con ciclo de vida

El nivel 2 te vendió una idea limpia: el reducer no ejecuta trabajo, devuelve una descripción de trabajo, y esa descripción es un valor. Esta lección cobra la letra pequeña de ese contrato. Un valor inerte no tiene problemas de identidad, pero un valor que el runtime convierte en una tarea concurrente sí los tiene, porque a partir de ese instante existe en el tiempo: empieza, dura y puede quedar desactualizado respecto a la intención que lo originó. Ahí nace toda la dificultad de la asincronía en interfaces, y conviene nombrarla con precisión: el problema no es que la red sea lenta, es que la intención del usuario cambia más rápido que las consecuencias de sus intenciones anteriores. Un efecto lanzado es una promesa hecha en un estado del mundo que quizá ya no exista cuando la promesa se cumpla. Por eso las soluciones que filtran a la salida siempre saben a parche: aceptan que el pasado siga hablando y se limitan a taparle la boca en el último momento, cuando el ancho de banda ya se gastó, el servidor ya trabajó y los efectos secundarios ya ocurrieron. Cancelar es la operación honesta, porque actúa sobre la causa: retira la promesa en el momento en que deja de tener sentido. Y observa lo que exige a cambio, que es la tesis de todo el nivel: para poder retirar una promesa hay que poder referirse a ella, es decir, darle una identidad estable e independiente de la Task concreta que la está ejecutando. Esa identidad —el CancelID— es el paso conceptual que faltaba para que el efecto fuera un ciudadano de primera clase de la arquitectura: no solo un valor que describe trabajo, sino un valor con nombre propio, del que se puede predicar si sigue vivo y sobre el que se puede actuar desde otra parte del sistema. Es exactamente lo mismo que el id de una vista en SwiftUI o la clave de una consulta en una caché: en cuanto algo dura en el tiempo, necesita nombre.

⚔️ Reproduce la carrera antes de arreglarla
  1. Monta un buscador con consultaCambiada y resultadosRecibidos sin cancelación. En el cliente falso, haz que el retardo dependa del texto: cuanto más corta la consulta, más tarda. Teclea deprisa y observa la pantalla retroceder.
  2. Escribe un test con TestStore que envíe dos consultas seguidas y reciba las respuestas en orden invertido. Comprueba que el estado final es el de la consulta antigua y que el test pasa: el defecto es especificable, no un accidente.
  3. Añade el guard texto == state.consulta y busca el agujero: borra una letra y vuelve a escribirla mientras la primera petición sigue viva. Explica por qué el filtro la deja pasar.
  4. Instrumenta el cliente falso con un contador de llamadas efectivamente realizadas. Compara el número de peticiones con y sin cancelInFlight: true para las mismas pulsaciones.
  5. Razona qué le ocurre al state.cargando en cada variante y decide si un booleano basta o si necesitas contar efectos vivos.