Core Concepts

View Active Connections

Last updated by Harlan Wilton in doc: sync.

Track how many users are connected to your application and which versions they're running. This is useful for admin dashboards, deployment monitoring, and understanding rollout progress.

Connection tracking only works with sse or ws update strategies. It does not support polling or external adapters (Pusher/Ably).

Setup

Enable connection tracking in your Nuxt config using connectionTracking:

nuxt.config.ts
export default defineNuxtConfig({
  skewProtection: {
    connectionTracking: true,
    routeTracking: true, // optional: track which routes users are viewing
    ipTracking: true, // optional: track user IP addresses
  }
})

Limitations

  • Single instance only: Stats are per-server process. With horizontal scaling, each instance only sees its own connections.
  • SSE/WS only: Does not work with polling strategy or external adapters (Pusher/Ably).
  • Bot traffic excluded: When @nuxtjs/robots is installed, bot/crawler traffic is automatically excluded from connection counts. See Bot Traffic Filtering.

IP Tracking

IP tracking requires explicit opt-in via ipTracking: true due to privacy considerations. When enabled, IP addresses are extracted from request headers in this order:

  1. cf-connecting-ip (Cloudflare)
  2. x-forwarded-for (proxies/load balancers)
  3. x-real-ip (nginx)
IPs are only stored in memory and exposed via the stats API. Ensure your authorization hook properly restricts access to stats data.

Authorization

Stats are only sent to connections that pass authorization. Implement the skew:authorize-stats hook in a server plugin.

server/plugins/skew-auth.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('skew:authorize-stats', async ({ event, authorize }) => {
    // Works with nuxt-auth-utils
    const session = await getUserSession(event)
    if (session?.user?.role === 'admin') {
      authorize()
    }
  })
})

Cloudflare with nuxt-auth-utils

For WebSocket connections (e.g., with cloudflare-durable preset), the event object is not a full H3 event—it only contains { headers: Headers }. This means getUserSession() won't work directly. You need to manually unseal the session cookie:

server/plugins/skew-auth.ts
import { unsealSession } from 'h3'

const ADMIN_EMAILS = ['admin@example.com']

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('skew:authorize-stats', async ({ event, authorize }) => {
    // Try getUserSession first (works for SSE/POST)
    const session = await getUserSession(event).catch(() => null)
    if (session?.user?.email && ADMIN_EMAILS.includes(session.user.email)) {
      authorize()
      return
    }

    // For WebSocket, manually unseal the session cookie
    if (event?.headers instanceof Headers) {
      const cookieHeader = event.headers.get('cookie') || ''
      const match = cookieHeader.match(/nuxt-session=([^;]+)/)
      if (match?.[1]) {
        const config = useRuntimeConfig()
        const password = config.session?.password || process.env.NUXT_SESSION_PASSWORD || ''
        if (password) {
          const unsealed = await unsealSession(null as any, { password }, decodeURIComponent(match[1])).catch(() => null)
          // Note: unsealSession returns { data: { user } }, not { user } directly
          const email = unsealed?.data?.user?.email
          if (email && ADMIN_EMAILS.includes(email)) {
            authorize()
          }
        }
      }
    }
  })
})
The key difference: getUserSession() returns { user: { email } } while unsealSession() returns { data: { user: { email } } }.

Using the Composable

The useActiveConnections() composable provides reactive access to connection statistics:

<script setup lang="ts">
import { useActiveConnections } from '#imports'

const { total, versions, authorized } = useActiveConnections()
</script>

<template>
  <div v-if="authorized">
    <p>Active users: {{ total }}</p>
    <ul>
      <li v-for="(count, version) in versions" :key="version">
        {{ version.slice(0, 8) }}: {{ count }} users
      </li>
    </ul>
  </div>
  <div v-else>
    Not authorized for stats
  </div>
</template>

Return Values

PropertyTypeDescription
totalComputedRef<number>Total active connections
versionsComputedRef<Record<string, number>>Map of buildId → connection count
routesComputedRef<Record<string, number>>Map of route → connection count (requires routeTracking)
connectionsComputedRef<ConnectionInfo[]>Individual connections with id, version, route, and ip (requires ipTracking for IP)
yourIdComputedRef<string | undefined>Your connection ID to identify yourself in the list
authorizedComputedRef<boolean | null>Whether the connection is authorized for stats (null while pending)

Example: Admin Dashboard

<script setup>
const { total, versions, authorized } = useActiveConnections()
const currentBuildId = useRuntimeConfig().app.buildId

const stats = computed(() => {
  const current = versions.value[currentBuildId] || 0
  const outdated = total.value - current
  return {
    total: total.value,
    current,
    outdated,
    percentUpToDate: total.value ? Math.round((current / total.value) * 100) : 100
  }
})
</script>

<template>
  <div v-if="authorized" class="grid grid-cols-4 gap-4">
    <div class="stat">
      <span class="label">Total Users</span>
      <span class="value">{{ stats.total }}</span>
    </div>
    <div class="stat">
      <span class="label">On Latest</span>
      <span class="value text-green">{{ stats.current }}</span>
    </div>
    <div class="stat">
      <span class="label">On Old Version</span>
      <span class="value text-orange">{{ stats.outdated }}</span>
    </div>
    <div class="stat">
      <span class="label">Rollout Progress</span>
      <span class="value">{{ stats.percentUpToDate }}%</span>
    </div>
  </div>
  <div v-else>
    Not authorized for live stats
  </div>
</template>

Custom Behavior with Hooks

For advanced use cases, you can use hooks directly instead of the composable.

Client-Side: skew:message

Listen to all messages from the SSE/WebSocket connection:

plugins/custom-stats.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hooks.hook('skew:message', (message) => {
    if (message.type === 'stats') {
      // Custom handling
      console.log('Connections:', message.total)
      console.log('Versions:', message.versions)
    }
  })
})

Server-Side: Connection Lifecycle

Build custom tracking logic with Nitro hooks:

server/plugins/custom-tracking.ts
import { defineNitroPlugin } from 'nitropack/runtime'

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('skew:connection:open', ({ id, version, route, ip, send }) => {
    console.log(`Client ${id} connected on ${version} from ${ip}`)

    // Send custom welcome message
    send({ type: 'welcome', serverTime: Date.now() })
  })

  nitroApp.hooks.hook('skew:connection:close', ({ id }) => {
    console.log(`Client ${id} disconnected`)
  })
})

See Nuxt Hooks and Nitro Hooks for full documentation.

Did this page help you?