Next.js: singleton instanciado dos veces (globalThis)

Un bus de eventos en memoria funciona en las pruebas, pero con next build y next start los eventos se descartan con un aviso como este, aunque instrumentation.ts registró los handlers al arrancar:

[EVENT_BUS] No handlers registered for event 'pedido.creado'. Event dropped.

Lo peor es que la petición termina bien: simplemente no ocurren los efectos secundarios, como devolver el stock o generar el asiento contable. El mensaje pertenece a un bus propio, no a Next.js, pero el problema afecta a cualquier estado a nivel de módulo (un Map, una caché, un cliente) que se inicialice en register() y se lea desde una ruta. Las comprobaciones de este artículo se hicieron con Next.js 16.4.0, con compilación de producción y next start.

Por qué pasa

La documentación de Next.js indica que la función register de instrumentation.ts se llama una vez cuando se inicia una nueva instancia del servidor, y que debe terminar antes de que el servidor atienda peticiones. No explica si los módulos que importas desde ahí son los mismos que cargan tus rutas, así que conviene no darlo por hecho.

En un proyecto mínimo de prueba no lo eran. El módulo del bus se evaluó dos veces dentro del mismo proceso: una para el código de instrumentation y otra para el código de la ruta. Cada evaluación ejecuta su propia línea const handlers = new Map():

instrumentation.ts      -> copia A del módulo -> Map con 1 handler
app/api/.../route.ts    -> copia B del módulo -> Map vacío -> "No handlers registered"

Los handlers se registraron en la copia A y la ruta publicó en la copia B. El comportamiento de JavaScript es el esperado: dos evaluaciones de un módulo producen dos Map independientes. Lo inesperado es que existan dos copias. Con el mismo proyecto compilado con next build --webpack el resultado fue idéntico, de modo que no es algo exclusivo de Turbopack.

Solución

  1. Confirma que hay dos copias con un contador temporal. Se guarda en globalThis porque es lo único que ambas copias comparten dentro del proceso:

    // lib/event-bus.ts (temporal)
    const g = globalThis as typeof globalThis & { __eventBusInstances?: number };
    g.__eventBusInstances = (g.__eventBusInstances ?? 0) + 1;
    export const moduleInstances = () => g.__eventBusInstances ?? 0;

    Escribe moduleInstances() en el log al final de register() y de nuevo al publicar un evento desde una ruta. Si el valor pasa de 1 a 2, el módulo se cargó dos veces.

  2. Ancla el registro en globalThis. Todas las copias del módulo usarán el mismo Map:

    // lib/event-bus.ts
    type Handler = (payload: unknown) => Promise<void> | void;
    type Registered = { name: string; run: Handler };
    
    const globalForBus = globalThis as typeof globalThis & {
      __eventBusRegistry?: Map<string, Registered[]>;
    };
    const registry = (globalForBus.__eventBusRegistry ??= new Map<string, Registered[]>());
    
    export function registerHandler(event: string, name: string, run: Handler) {
      const list = registry.get(event) ?? [];
      if (list.some((h) => h.name === name)) return; // idempotente por (evento, nombre)
      registry.set(event, [...list, { name, run }]);
    }
    
    export async function publish(event: string, payload: unknown) {
      const list = registry.get(event);
      if (!list || list.length === 0) {
        throw new Error(`[EVENT_BUS] No handlers registered for event '${event}'.`);
      }
      for (const handler of list) await handler.run(payload);
    }

    Es el mismo patrón que la documentación de Prisma recomienda para el cliente de Prisma en Next.js. Hay una diferencia importante: ese ejemplo solo guarda la instancia cuando NODE_ENV no es production, porque su objetivo son las recargas en desarrollo. Aquí la asignación debe hacerse también en producción. La documentación de Node.js marca global como heredado y recomienda usar globalThis.

  3. Haz idempotente el registro. En el código anterior, registerHandler ignora una segunda llamada con el mismo par (evento, nombre). Si el módulo se evalúa varias veces o register se ejecuta de nuevo, el handler no se duplica ni se ejecuta dos veces por evento.

  4. Falla en voz alta cuando no hay handlers. Un evento sin handlers casi siempre es un error de configuración, no un caso normal. Lanzar una excepción (como arriba) hace visible el problema en la primera petición. Si el publicador no puede interrumpir el flujo principal, sustituye el throw por console.error y conecta ese error a tus alertas; lo que no conviene es descartar el evento con un simple warn y seguir.

  5. Registra los handlers solo en el runtime de Node.js y con la importación dentro de register, como recomienda la documentación:

    // instrumentation.ts
    export async function register() {
      if (process.env.NEXT_RUNTIME === 'nodejs') {
        const { registerHandler } = await import('./lib/event-bus');
        registerHandler('pedido.creado', 'descontar-stock', async (payload) => {
          // descontar stock
        });
      }
    }

Cómo verificar que quedó resuelto

Prueba siempre contra la compilación de producción, que es donde se comprobó el problema. Con una ruta temporal que publique un evento y devuelva los contadores:

npm run build
npm run start
curl http://localhost:3000/api/diagnostico

Antes del cambio, la respuesta de la prueba fue {"moduleInstances":2,"handlersVistosPorLaRuta":0} y el log mostró el aviso No handlers registered. Después del cambio fue {"moduleInstances":2,"handlersVistosPorLaRuta":1}, el handler se ejecutó y un evento sin handlers devolvió HTTP 500 con el error en el log del servidor.

Fíjate en que moduleInstances sigue siendo 2: la corrección no elimina la copia duplicada, hace que ambas compartan el mismo estado. Cuando hayas confirmado el resultado, quita el contador y la ruta temporal. Una prueba unitaria normal no detecta este fallo, porque aparece al empaquetar la aplicación; por eso conviene una comprobación de humo contra el servidor de producción.

Variantes y errores relacionados

Recargas en desarrollo

La documentación de Prisma explica que la recarga en caliente de Next.js vuelve a cargar los módulos con frecuencia, lo que puede crear varias instancias de un cliente. La solución es la misma técnica, con la diferencia de producción indicada arriba.

Varios procesos o contenedores

globalThis solo se comparte dentro de un proceso. Si ejecutas varias réplicas, un bus en memoria no cruza de una a otra. Cuando los eventos no pueden perderse, usa una cola o una tabla de eventos pendientes.

Runtime edge

Next.js llama a register en todos los entornos. Condiciona el código que solo funciona en Node.js con process.env.NEXT_RUNTIME === 'nodejs', como en el ejemplo.

Otro estado a nivel de módulo

Cachés, clientes de bases de datos o configuración cargada en register pueden sufrir lo mismo. Revisa cualquier variable de módulo que se escriba en un lugar y se lea en otro.

Alternativa: no depender de instrumentation

Otra opción es que el propio módulo del bus registre sus handlers la primera vez que se usa. Así cada copia se inicializa sola y el resultado no depende de cómo se empaquete la aplicación. A cambio, el trabajo de arranque se traslada a la primera petición.

Resumen

  • Un módulo con estado (Map, caché, cliente) puede evaluarse más de una vez en el mismo proceso: una copia para instrumentation.ts y otra para las rutas.
  • Detecta las copias con un contador en globalThis y comprueba el resultado con next build y next start, no solo en desarrollo.
  • Guarda el registro en globalThis (también en producción) para que todas las copias compartan el mismo estado.
  • Haz el registro idempotente por (evento, nombre) y falla en voz alta si un evento no tiene handlers.

Referencias

Comentarios