StackPractices
intermediate Por Mathias Paulenko

Feature Flags: Release Progresivo y Experimentación Segura

Guía práctica de feature flags: patrones de implementación, rollouts progresivos, kill switches, A/B testing y gestión del ciclo de vida a escala.

Los feature flags desacoplan el despliegue del release: publicas código en producción con la funcionalidad nueva oculta, luego la habilitas para usuarios, regiones o porcentajes específicos — y la desactivas al instante si algo falla, sin redesplegar. Esta guía cubre tipos de flags, una implementación funcional, rollout progresivo con gates de decisión y la disciplina de ciclo de vida que evita que los flags se conviertan en deuda.

Resumen

Cada feature flag crea dos caminos de código donde había uno. Eso te compra seguridad — despliegas el viernes, lanzas el martes — pero cada flag es una bifurcación temporal en tu codebase que alguien tiene que testear, monitorear y eventualmente remover. Los equipos que tratan los flags como andamiaje avanzan más rápido; los que los tratan como arquitectura se ahogan en deuda de flags.

Lo que sigue asume que ya puedes desplegar bajo demanda y que tienes dónde guardar el estado de los flags (una fila de base de datos, un servicio de configuración o una plataforma dedicada).

Una aclaración de entrada: un feature flag no es un sustituto de los tests ni una configuración remota. Los flags son un mecanismo de release — controlan cuál de dos caminos ya testeados corre. Si los usas para evitar testear la rama deshabilitada, o para ajustar constantes que deberían vivir en config, tienes la herramienta equivocada para ambos trabajos.

Cuándo Usarlos

  • Desplegar cambios incompletos sin exponerlos a usuarios
  • Lanzar gradualmente mientras observas tasas de error y métricas de negocio
  • Hacer A/B testing de cambios con tráfico real
  • Kill switches de emergencia para rutas riesgosas (pagos, migraciones)
  • Mergear a main a diario en lugar de mantener branches de larga duración

Si lo que necesitas es experimentación en lugar de control de release — tests de hipótesis con análisis estadístico — la guía de A/B testing cubre esa capa.

Conceptos Clave

ConceptoDescripción
Feature FlagUn check condicional que habilita o deshabilita un camino de código
Kill SwitchUn flag que deshabilita instantáneamente un cambio en producción
Rollout ProgresivoAumentar gradualmente el porcentaje de usuarios que ven un cambio
Flag DirigidoUn flag habilitado para usuarios, grupos o regiones específicas
Contexto de EvaluaciónLos atributos (ID de usuario, plan, región) contra los que se evalúa un flag
Vida del FlagEl período desde la creación hasta la remoción permanente del código
Deuda de FlagsFlags viejos acumulados que saturan código y configuración

Tipos de Feature Flags

El tipo determina todo lo demás — vida útil, responsable, monitoreo y procedimiento de remoción:

TipoCaso de UsoVida
Flag de releaseOcultar cambios incompletos durante desarrolloCorta (días a semanas)
Flag de experimentoA/B testing y decisiones basadas en datosMedia (semanas a meses)
Flag operacionalCircuit breakers, límites de rate, modos debugLarga (meses a permanente)
Flag de permisoAcceso a capacidades por tier de usuarioPermanente (mejor con RBAC)
Kill switchDeshabilitación de emergencia para cambios riesgososCorta (se remueve tras estabilizar)

Dos reglas derivadas de esa tabla: etiqueta cada flag con su tipo al crearlo — la política de remoción se desprende de ahí — y trata los flags de permiso como un code smell. Los flags que gobiernan acceso de forma permanente son autorización con otro sombrero.

Reglas de Targeting y Contexto de Evaluación

El rollout por porcentaje es solo un eje. Los flags se resuelven contra un contexto de evaluación — los atributos que pasas: ID de usuario, tier de plan, país, dispositivo, versión de la app. Un conjunto de reglas típico evalúa en orden:

  1. Override de kill — flag apagado para todos gana sobre todo lo demás
  2. Targets explícitos — IDs de usuario específicos o una allowlist
  3. Reglas de segmento — plan = 'enterprise' o country = 'DE'
  4. Rollout por porcentaje — bucketing determinista sobre el tráfico restante
  5. Default — lo que recibe quien no matchea nada

Mantén el contexto pequeño y estable. Cada atributo que añades es una dimensión en la que tus flags pueden diverger — “habilitado para usuarios móviles de la UE en app v3” son tres ejes de sorpresa. Y pasa un identificador de usuario consistente: flags evaluados sobre email en un servicio y user_id en otro producen usuarios que ven ambas experiencias a la vez.

Implementar un Sistema de Flags

Decisión build vs buy:

OpciónMejor ParaEjemplos
Open-sourceAuto-hospedado, control totalUnleash, Flagsmith, Flipt
SaaSSetup rápido, escala gestionadaLaunchDarkly, Split, Optimizely
Build propioCasos simples, integración estrechaConfig en-app + base de datos
Archivos de configFlags estáticos, sin cambios en runtimeConfigs YAML/JSON

Si construyes uno propio, apunta al contrato de OpenFeature — una API de evaluación neutral al proveedor — para que migrar a un proveedor gestionado más adelante no signifique reescribir los call sites.

from dataclasses import dataclass
from typing import Optional
import hashlib

@dataclass
class FeatureFlag:
    name: str
    enabled: bool
    rollout_percentage: float = 100.0
    target_users: Optional[list[str]] = None

class FeatureFlagManager:
    def __init__(self):
        self.flags = {}

    def register(self, flag: FeatureFlag):
        self.flags[flag.name] = flag

    def is_enabled(self, flag_name: str, user_id: str = None) -> bool:
        flag = self.flags.get(flag_name)
        if not flag or not flag.enabled:
            return False

        if flag.target_users and user_id:
            return user_id in flag.target_users

        if flag.rollout_percentage < 100 and user_id:
            # Hash determinista: el mismo usuario siempre cae en el mismo bucket
            digest = hashlib.md5(f"{flag.name}:{user_id}".encode()).hexdigest()
            return int(digest, 16) % 100 < flag.rollout_percentage

        return True

ffm = FeatureFlagManager()
ffm.register(FeatureFlag("new-dashboard", enabled=True, rollout_percentage=10))

if ffm.is_enabled("new-dashboard", user_id="user-123"):
    show_new_dashboard()
else:
    show_legacy_dashboard()

El bucketing con md5 es donde la gente se equivoca. random.random() < 0.10 da una respuesta distinta en cada llamada — el mismo usuario alterna entre la experiencia vieja y la nueva dentro de una misma sesión, y tu rampa del 10 % es en realidad el 10 % de las requests, no de usuarios. Hashear flag.name + user_id hace la asignación determinista, así que subir del 10 % al 50 % mantiene al 10 % original en el grupo de tratamiento.

Versión TypeScript del mismo contrato (para backends o edge workers — usa un hash no criptográfico como FNV-1a o un digest de crypto.subtle, lo importante es el determinismo):

import { createHash } from "node:crypto";

interface FeatureFlag {
  enabled: boolean;
  rolloutPercentage: number;
  targetUsers?: string[];
}

function isEnabled(flagName: string, flag: FeatureFlag | undefined, userId?: string): boolean {
  if (!flag?.enabled) return false;
  if (flag.targetUsers && userId) return flag.targetUsers.includes(userId);
  if (flag.rolloutPercentage < 100 && userId) {
    const hash = createHash("md5").update(`${flagName}:${userId}`).digest("hex");
    return parseInt(hash, 16) % 100 < flag.rolloutPercentage;
  }
  return true;
}

Rollout Progresivo con Gates de Decisión

Aumenta la exposición por etapas, y define los criterios para avanzar antes de empezar:

ROLLOUT_STAGES = [
    {"name": "dev-team",    "percentage": 0,   "target_users": ["dev1", "dev2", "qa1"]},
    {"name": "1-percent",   "percentage": 1,   "target_users": None},
    {"name": "10-percent",  "percentage": 10,  "target_users": None},
    {"name": "50-percent",  "percentage": 50,  "target_users": None},
    {"name": "full",        "percentage": 100, "target_users": None},
]

Una progresión que funciona en la práctica:

  1. Solo internos — equipo de desarrollo y QA, verificar que la funcionalidad funciona
  2. Anillo beta — early adopters afines, verificar la UX
  3. 1 % — detectar crashes y problemas de carga a bajo coste
  4. 10 % — validar tasas de error y p99 a escala
  5. 50 % — confirmar que las métricas de negocio se mantienen
  6. 100 % — luego estabilizar 1-2 semanas antes de remover el flag

Los gates de decisión entre etapas son lo que separa esto de “subir el porcentaje y rezar”. Antes de cada subida verifica: paridad de tasa de error (cohortes habilitado vs deshabilitado), delta de latencia p99 y al menos una métrica de negocio (activación, conversión — la que el flag toque). Si algún gate falla, no haces rollback de un despliegue — volteas el flag, lo cual tarda segundos.

Flags para Migraciones de Base de Datos

Los flags son la mitad en runtime del patrón expand-contract para cambios de esquema. La secuencia segura: añade la columna/tabla nueva, despliega código que escribe en ambas (dual-write) detrás de un flag, backfill, voltea el flag para leer de la ubicación nueva y luego — en un despliegue posterior — remueve el flag y borra la columna vieja.

Cada paso es reversible porque el flag controla qué representación es autoritativa, no el esquema. Si la nueva lectura falla, vuelves atrás; los datos siguen correctos en ambos sitios. Intentar lo mismo solo con despliegues te obliga al fin de semana de “gran migración”.

Kill Switches

Flags que existen solo para deshabilitar una ruta al instante:

def process_payment(order):
    if not feature_flags.is_enabled("payment-processing-v2"):
        return process_payment_v1(order)

    try:
        return process_payment_v2(order)
    except Exception:
        if feature_flags.is_enabled("payment-auto-fallback"):
            return process_payment_v1(order)
        raise

Pautas que importan: todo cambio riesgoso sale detrás de un kill switch por defecto; los valores de los flags se cachean en proceso para que la evaluación sume microsegundos, no una llamada de red; alguien practica el camino de apagado (un switch que nadie ha volteado nunca es una esperanza, no un control); y el runbook dice qué flags son kill switches y quién puede voltearlos.

Una advertencia sobre el auto-fallback del ejemplo: capturar Exception y reintentar contra v1 puede enmascarar un bug real y cobrar dos veces un pago que se completó a medias. Auto-fallback solo para operaciones seguras de reintentar, y alerta cada vez que se dispare — el fallback silencioso es fallo silencioso.

También conviene decidirlo pronto: quién puede voltear un kill switch. Si voltearlo requiere un despliegue o tres niveles de aprobación, no es un kill switch — es una solicitud de cambio. Dale a la rotación de guardia la capacidad de voltear flags de seguridad sin desplegar, y audita cada volteo para que el log de flags funcione también como línea temporal del incidente.

Monitorear el Rendimiento de los Flags

MétricaPor Qué Importa
Latencia de evaluaciónLos checks de flag suman overhead a cada request
Tasa de error por estado¿La ruta habilitada es realmente peor?
Engagement por cohorteCompara grupos on/off en la métrica que el flag toca
Impacto en conversiónEfecto de negocio del cambio
Antigüedad del flagLos flags vencidos son deuda, no funcionalidad
panels:
  - title: "Tasa de Evaluación de Flags"
    query: 'rate(feature_flag_evaluations_total[5m])'
  - title: "Kill Switches Activos"
    query: 'feature_flag_enabled{name=~".*-kill-switch"}'
  - title: "Flags Estancados"
    query: 'time() - feature_flag_last_modified > 7776000'  # >90 días

El panel de tasa de error por estado se paga solo desde el día uno: convierte “¿el rollout rompió algo?” en un gráfico en lugar de una suposición.

Los Flags de Experimento Necesitan Otra Fontanería

Un flag de experimento no es solo un flag de release con dashboard. Para que los datos signifiquen algo necesitas un evento de exposición — registrar “el usuario X vio la variante Y en el instante T” — porque eso es lo que define tus cohortes. Sin él estás comparando a quien quiso convertir, no a quien fue expuesto.

Dos reglas se desprenden: no reutilices un flag de release como experimento a mitad de camino (el cohorte canary del 1 % contamina tu grupo de test), y mantén la medición del experimento separada del monitoreo del rollout — una responde “¿es seguro?”, la otra “¿es mejor?”.

Ciclo de Vida y Deuda de Flags

Los flags tienen ciclo de vida lo gestiones o no — no gestionarlo solo significa que la deuda se acumula en silencio:

flowchart diagram: Crear flag\nowner + tipo + expiración

Operativamente:

# Workflow de limpieza
# 1. Listar flags habilitados >30 días sin cambios
# 2. Confirmar que la funcionalidad es estable y está al 100%
# 3. Ticket de remoción — decide el owner del flag, no "alguien"
# 4. Remover el condicional, conservar solo la rama habilitada
# 5. Remover el flag de la config, desplegar, verificar regresiones

Reglas que previenen la deuda: fecha de expiración en cada flag de release y experimento al crearlo (30-60 días); tope de flags activos por servicio — los codebases sanos se mantienen por debajo de ~20-30, y por encima de eso cada matriz de tests y cada revisión de código se complica; archiva los flags removidos en un changelog para que “¿cuándo habilitamos X?” tenga respuesta.

El naming ayuda más de lo que parece. new-dashboard-v2-final no le dice nada a nadie; {area}-{comportamiento}-{ticket} (checkout-retry-logic-4821) hace el flag grepeable, lo ata a un responsable y deja claro qué borrar. Si por el nombre no puedes decir si un flag debería seguir existiendo, probablemente no debería.

Feature Flags y Trunk-Based Development

Los flags son lo que hace seguro mergear trabajo incompleto. En lugar de un feature branch que vive tres semanas y se desvía de main, mergeas piezas pequeñas detrás de un flag deshabilitado — main sigue siendo desplegable, el branch nunca se pudre y el review ocurre en trozos revisables.

La disciplina que lo hace funcionar: el camino deshabilitado tiene que seguir compilando y pasando tests. Mergear código muerto “porque está detrás de un flag” se pudre igual que un branch — cuando por fin lo habilitas, no compila o rompe silenciosamente el camino que lo rodea. CI debería compilar y hacer smoke-test de la ruta con flag aunque esté apagada.

Flags del Servidor vs del Cliente

Dónde se evalúa un flag cambia para qué sirve:

AspectoLado del servidorLado del cliente
LatenciaEvaluación al inicio del requestFetch del SDK + evaluación local
Exposición del payloadLa ruta oculta nunca llega al clienteEl código deshabilitado va en el bundle — y es legible
ConsistenciaAutoritativa por requestPuede desfasarse hasta que el SDK refresca
Bueno paraPagos, lógica de negocio, experimentosVariantes de UI, tema, layout

Dos consecuencias prácticas: nunca pongas una ruta peligrosa solo detrás de un flag del cliente (el código es inspeccionable en el navegador), y para experimentos evalúa del lado del servidor o tendrás flicker — la UI vieja renderizando antes de que cargue el flag.

Llevar el Estado de los Flags a la Aplicación

Cómo llegan los valores a tu código es una decisión de disponibilidad: una llamada remota por evaluación pone al servicio de flags en tu ruta crítica — si cae, todo evalúa al default (por eso importa que el default sea seguro). Patrones comunes, en orden de peso operacional:

  • SDK en proceso con polling o streaming — el SDK mantiene una copia local; la evaluación es un lookup de diccionario. La mayoría de setups de producción aterrizan aquí.
  • Agente sidecar — un proceso local (como el agente de AWS AppConfig) mantiene el estado y tu app llama a localhost. Mismo beneficio, amigable a multi-lenguaje.
  • Evaluación remota — lo más simple para empezar; aceptable en herramientas de poco tráfico, riesgoso como dependencia por request.

Sea cual sea el mecanismo, el estado viejo de los flags es normal — decide cuánto desfase es aceptable. Un kill switch que tarda cinco minutos en propagarse es un “botón de pausa”, que a veces es exactamente lo que necesitas y a veces no sirve.

Un Flag, Un Punto de Decisión

La convención más valiosa: cada flag tiene exactamente un punto de decisión con nombre — un helper tipo features.newDashboard() — llamado desde donde sea necesario. Lo que no haces es esparcir if flags["new-dashboard"] por doce archivos, porque remover el flag se convierte en un problema de recorrido de grafo y la mitad de los call sites no coincide en el default.

# features.py — el único lugar donde viven los nombres de flags
def new_dashboard_enabled(user_id: str) -> bool:
    return ffm.is_enabled("new-dashboard", user_id)

Cuando el flag se jubila, borras el check del helper e incorporas la rama True — un solo diff, sin arqueología.

Lo Que Funciona

  • Un flag por cambio, nunca condicionales anidados — los flags combinatorios no se pueden testear.
  • Default seguro: si el sistema de flags cae, el flag evalúa al comportamiento probado, no al nuevo.
  • Evalúa una vez por request, cachea el resultado, propágalo por el contexto — no reevalúes a mitad de request.
  • Testea ambos caminos, y haz smoke-test del camino deshabilitado en CI — es el estado que la mayoría de usuarios ve. (La caída de Slack de mayo de 2020 escaló precisamente porque una ruta con flag activo nunca había corrido bajo carga real de producción.)
  • Owner, descripción, tipo y expiración en cada flag desde el día uno.

Errores Comunes

  • Dejar flags en el código indefinidamente — los flags estancados son ramas muertas, config vieja e interacciones sorpresa. Pasados ~30 días al 100 %, un flag es deuda.
  • Bucketing con Math.random() — experiencia inconsistente por usuario y datos de experimento contaminados; usa hashing determinista.
  • Checks de flag esparcidos — sin un punto de decisión único, remover un flag exige una expedición por el codebase.
  • Flags como control de acceso — el gating de permisos de larga duración pertenece a RBAC, no al store de flags.
  • Testear solo el camino habilitado — la rama deshabilitada es lo que producción ejecuta la mayoría del tiempo.
  • Interdependencias entre flags — “habilitar B solo si A” crea una matriz de pruebas que nadie verifica; mantén los flags ortogonales.
  • Auto-fallback silencioso — las rutas de fallback que no alertan esconden el fallo que debían sobrevivir.

Ver También

Preguntas frecuentes

¿Cuál es la diferencia entre un feature flag y un toggle de configuración?

Vida útil y alcance. Un feature flag es de corta duración, está ligado a un camino de código y se remueve cuando el cambio sale. Un toggle de configuración es un setting operacional de larga duración (nivel de log, límite de rate) que se queda en el sistema. Ambos usan el mismo mecanismo — la diferencia es si alguien planea borrarlo.

¿Cómo manejo feature flags en un sistema distribuido?

Centraliza el estado de los flags en un servicio de flags o config store, evalúa una vez al inicio del request y propaga el resultado por el contexto del request para que los servicios de abajo vean una vista consistente. Sin propagación, el servicio A ve el flag activo y el B lo ve apagado en mitad de un request — así nacen los pedidos procesados a medias.

¿Pueden los feature flags ralentizar mi aplicación?

Sí, si se evalúan de forma ingenua — una llamada remota por check de flag por request suma latencia rápido. Se arregla cacheando los valores en proceso (con TTL corto o push updates), evaluando una sola vez por request y manteniendo los checks fuera de los bucles intensivos.

¿Cuándo debería remover un feature flag?

En cuanto el cambio sea estable y esté al 100 % — típicamente 1-2 semanas tras el rollout completo, y dentro de 30 días como objetivo duro. Si un flag lleva 90 días activo sin tocarse, ya no es un flag; es comportamiento indocumentado.

¿El bucketing de flags debería usar hash o asignación aleatoria?

Hashing determinista de nombre del flag + ID de usuario. La asignación aleatoria da al mismo usuario respuestas distintas entre requests, rompe la consistencia de sesión y corrompe los cohortes de experimento. El hashing hace que "10 % de rollout" sea el 10 % de los usuarios, de forma repetible — y al subir el porcentaje nunca degradas a usuarios ya dentro.

¿Evaluación de flags del lado del servidor o del cliente?

Del servidor para todo lo que sea crítico en corrección o negocio — pagos, precios, experimentos donde el flicker contamina los datos. Del cliente solo para variaciones de UI donde una resolución tardía del flag es inofensiva. Nunca gobiernes una ruta sensible solo con un flag del cliente: el código deshabilitado igualmente sale en el bundle.

¿Cuántos flags son demasiados?

No hay número duro, pero ~20-30 flags activos por servicio es un techo razonable — por encima, testear ambos estados de cada flag se vuelve inmanejable y nadie sabe qué hace la mitad de ellos. Si estás por encima, la solución es disciplina de ciclo de vida (expiración al crear, revisión mensual), no un store de flags más grande.