Advanced

Performance

Optimize authentication and SSR performance with static JWKS and caching strategies.

Static JWKS

By default, Better Auth fetches JWKS (JSON Web Key Set) from the database for every JWT sign/verify operation. This adds 2-4 database calls per token operation.

Static JWKS eliminates these database lookups by storing keys in an environment variable.

When to use

  • High-traffic authenticated SSR pages
  • When you notice slow initial page loads
  • Reducing Convex function calls/bandwidth

Setup

1. Add the JWKS generation action to your Convex backend:

convex/auth.ts
import { internalAction } from './_generated/server'

export const getLatestJwks = internalAction({
  args: {},
  handler: async (ctx) => {
    const auth = createAuth(ctx)
    return await auth.api.getLatestJwks()
  },
})

2. Generate and set the JWKS environment variable:

npx convex run auth:getLatestJwks | npx convex env set JWKS

3. Update your auth config to use static JWKS:

convex/auth.config.ts
import { getAuthConfigProvider } from '@convex-dev/better-auth/auth-config'
import type { AuthConfig } from 'convex/server'

const jwks = process.env.JWKS

export default {
  providers: [getAuthConfigProvider({ jwks })],
} satisfies AuthConfig

4. Update the Better Auth plugin:

convex/auth.ts
const jwks = process.env.JWKS

export const createAuth = (ctx: GenericCtx<DataModel>) => {
  return betterAuth({
    // ... other config
    plugins: [
      convex({ authConfig, jwks }),
    ],
  })
}

Trade-offs

AspectDynamic JWKS (default)Static JWKS
SetupAutomaticRequires CLI command
Performance2-4 DB calls per token op0 DB calls
Key rotationAutomaticRe-run command + redeploy
Key storageDatabaseEnvironment variable

When to regenerate

  • After intentional key rotation
  • If you see JWT verification errors
  • After a fresh deployment without existing keys
npx convex run auth:getLatestJwks | npx convex env set JWKS

Disable Subscriptions

For data that doesn't need real-time updates, skip WebSocket subscriptions with subscribe: false:

const { data, refresh } = await useConvexQuery(
  api.config.getSettings,
  {},
  { subscribe: false }
)

Benefits

  • Reduces WebSocket connections
  • Fewer Convex function calls
  • Lower bandwidth usage

When to use

  • Configuration/settings that rarely change
  • Historical data or reports
  • Search results (use refresh() to re-fetch)
  • Static content

Manual refresh

With subscribe: false, data won't update automatically. Use refresh() when needed:

const { data, refresh } = await useConvexQuery(
  api.posts.search,
  computed(() => query.value ? { q: query.value } : 'skip'),
  { subscribe: false }
)

// Trigger search manually
async function doSearch() {
  await refresh()
}

Token Caching

The module automatically caches tokens on the client to avoid redundant validation requests. When Convex's client requests a token refresh, we return the cached token if it was validated recently (within 10 seconds).

This is handled automatically - no configuration needed.


SSR Auth Token Caching

Reduce SSR latency by caching Convex JWT tokens across requests. By default, every SSR request fetches a fresh token from the auth server (50-200ms). With caching enabled, subsequent requests use the cached token instantly.

Enable Caching

nuxt.config.ts
export default defineNuxtConfig({
  convex: {
    authCache: {
      enabled: true,
      ttl: 900 // 15 minutes (in seconds)
    }
  }
})

How It Works

  1. First SSR request: Fetches token from auth server, caches it
  2. Subsequent requests: Uses cached token (near-zero latency)
  3. After TTL expires: Fetches fresh token

Multi-Instance Deployments (Redis)

For deployments with multiple server instances (e.g., serverless, horizontal scaling), use Redis to share the cache:

nuxt.config.ts
export default defineNuxtConfig({
  convex: {
    authCache: {
      enabled: true,
      ttl: 900
    }
  },
  nitro: {
    storage: {
      'cache:convex:auth': {
        driver: 'redis',
        url: process.env.REDIS_URL
      }
    }
  }
})

Cache Invalidation on Logout

To immediately invalidate the cache when a user logs out:

server/api/logout.post.ts
import { clearAuthCache } from '#imports'

export default defineEventHandler(async (event) => {
  const sessionToken = getCookie(event, 'better-auth.session_token')

  if (sessionToken) {
    await clearAuthCache(sessionToken)
  }

  // ... rest of logout logic
})

Configuration Options

OptionTypeDefaultDescription
enabledbooleanfalseEnable SSR auth token caching
ttlnumber900Cache TTL in seconds (15 min default)

Trade-offs

AspectWithout CacheWith Cache
TTFB+50-200ms per requestNear-zero after first request
Session revocationImmediateUp to TTL delay (use clearAuthCache for immediate)
InfrastructureNoneMemory (default) or Redis

When to Use

  • High-traffic authenticated pages
  • Multi-page navigation patterns
  • When TTFB is critical

When to Skip

  • Security-critical apps requiring instant session revocation
  • Single-page apps with minimal SSR
  • Development environments

Query Deduplication

Multiple components calling the same query share a single subscription. The module uses useState keys based on query name and arguments.

<!-- Both components share the same subscription -->
<template>
  <Header /> <!-- calls useConvexQuery(api.user.current) -->
  <Sidebar /> <!-- also calls useConvexQuery(api.user.current) -->
</template>

No duplicate fetches, no duplicate subscriptions.


Understanding Duplicate Query Calls

In your Convex dashboard, you may see queries called twice:

Q auth:getCurrentUser (HTTP API)     <- SSR
Q teams:listMyTeams   (HTTP API)     <- SSR
Q auth:getCurrentUser (WebSocket)    <- Client subscription
Q teams:listMyTeams   (WebSocket)    <- Client subscription

This is expected behavior. Here's why:

CallPurposeWhen
HTTP APISSR - embed data in HTMLServer render
WebSocketReal-time subscriptionClient hydration

Why both are needed

  1. HTTP (SSR): One-time fetch to include data in the initial HTML. Fast first paint, SEO-friendly.
  2. WebSocket (Subscription): Ongoing connection for real-time updates. If another user changes data, your UI updates automatically.

Are they expensive?

No. Notice they show (cached) and 0ms duration:

success (cached) Q auth:getCurrentUser  <- 0ms, no re-computation

Convex is smart - the WebSocket subscription returns cached data instantly since the query result hasn't changed.

When to optimize

Option 1: subscribe: false - SSR only, no WebSocket subscription:

const { data } = await useConvexQuery(
  api.config.get,
  {},
  { subscribe: false }
)

Dashboard shows:

Q config:get (HTTP API)  <- SSR only

Option 2: server: false - Client only, no SSR:

const { data } = await useConvexQuery(
  api.session.get,
  {},
  { server: false }
)

Dashboard shows:

Q session:get (WebSocket)  <- Client only

Option 3: Both - No SSR, no subscription (one-time client fetch):

const { data, refresh } = await useConvexQuery(
  api.reports.get,
  {},
  { server: false, subscribe: false }
)

Dashboard shows:

Q reports:get (HTTP API)  <- Single client fetch via HTTP

Measuring Performance

Use browser DevTools Network tab to measure:

  • TTFB (Time to First Byte): Should improve with static JWKS
  • Token endpoint time: /api/auth/convex/token response time
  • Convex dashboard: Check function call counts

Before static JWKS, you'll see adapter:findMany calls for JWKS. After, these disappear.