Skip to content

Add a debounced or cahced flag access option to reduce the number of feature events LD sends #366

@buffginger

Description

@buffginger

Is your feature request related to a problem? Please describe.
Lets say you have a flag that is used across 5 components on a page. When that page renders, 5 events would get sent - except this is react and re-renders happen on very fast when loading components, so those 5 events multiply into 70 events sent within one second. Now multiply that flag issue for 3 more flags you have on that page, and you're sending over 300 events in a second. It quickly scales out of control, and saying we should reduce our re-renders is a very difficult task in large and old code bases.

My ask is to make a useCachedFlag() hook that allows to skip the observable event every x seconds. So in the example above, we would only send one event (using a cache) or three events (using debounce)

For context, we were bringing in anywhere between 150 million and 210 million rows of data a day during peak activity. Our homepage on load would send 120 events, and our product page would sent 250.

Describe the solution you'd like
I've made two different solutions in my own codebase that work great.

Debounced Method

import { useLDClient } from 'launchdarkly-react-client-sdk'
import { useEffect, useRef, useState } from 'react'
/**
 * Get a specific flag with debouncing to prevent duplicate evaluations
 *
 * If you need to pass in a default value, then consider making a wrapper around this function that returns the default value when this flag returns false.
 *
 * Usage:
 * Boolean flags: const b4b_24856_coralogix = useDebouncedFlag('b4b_24856_coralogix')
 * JSON flags: const b4b_19026_experience_config = useDebouncedFlag<ExperienceConfig>(
		'b4b_19026_experience_config',
		defaults.b4b_19026_experience_config,
	)
 */
export function useDebouncedFlag<T = boolean>(flagKey: string, defaultValue: any = false): T {
	const ldClient = useLDClient()
	const [flagValue, setFlagValue] = useState<T>(defaultValue)
	const lastEvaluationRef = useRef<{ timestamp: number; value: T }>({
		timestamp: 0,
		value: defaultValue,
	})
	const debounceTimeoutRef = useRef<null | ReturnType<typeof setTimeout>>(null)

	useEffect(() => {
		if (!ldClient) return

		// Clear any pending debounced evaluation
		if (debounceTimeoutRef.current) {
			clearTimeout(debounceTimeoutRef.current)
		}

		// Debounce flag evaluation - only evaluate if enough time has passed
		debounceTimeoutRef.current = setTimeout(() => {
			const now = Date.now()
			const timeSinceLastEval = now - lastEvaluationRef.current.timestamp

			// Only evaluate if it's been at least 30 seconds since last evaluation
			if (timeSinceLastEval > 30000) {
				const value = ldClient.variation(flagKey, defaultValue) as T
				lastEvaluationRef.current = { timestamp: now, value }
				setFlagValue(value)
			} else {
				// Use cached value
				setFlagValue(lastEvaluationRef.current.value)
			}
		}, 50) // 50ms debounce

		return () => {
			if (debounceTimeoutRef.current) {
				clearTimeout(debounceTimeoutRef.current)
			}
		}
	}, [ldClient, flagKey, defaultValue])

	return flagValue
}

Cached Method

import { useLDClient } from 'launchdarkly-react-client-sdk'
import { useEffect, useState } from 'react'

type FlagCacheEntry<T = any> = {
	timestamp: number
	value: T
}

// Shared cache across all hook instances to prevent duplicate LD feature events
// Key: flagKey, Value: { timestamp, value }
const flagCache = new Map<string, FlagCacheEntry>()

/**
 * Get a specific flag with shared caching to prevent duplicate evaluations
 *
 * Uses a shared module-level cache so multiple components using the same flag
 * only trigger one LaunchDarkly feature event per 30-second window.
 *
 * If you need to pass in a default value, then consider making a wrapper around this function that returns the default value when this flag returns false.
 *
 * Usage:
 * Boolean flags: const b4b_24856_coralogix = useCachedFlag('b4b_24856_coralogix')
 * JSON flags: const b4b_19026_experience_config = useCachedFlag<ExperienceConfig>(
		'b4b_19026_experience_config',
		defaults.b4b_19026_experience_config,
	)
 */
export function useCachedFlag<T = boolean>(flagKey: string, defaultValue: any = false): T {
	const ldClient = useLDClient()
	const [flagValue, setFlagValue] = useState<T>(defaultValue)

	useEffect(() => {
		if (!ldClient) return

		const now = Date.now()
		const cached = flagCache.get(flagKey)
		const timeSinceLastEval = cached ? now - cached.timestamp : Infinity

		// Check shared cache first - if valid, use cached value (no LD event)
		if (cached && timeSinceLastEval <= 30000) {
			setFlagValue(cached.value)
		} else {
			// Evaluate flag and update shared cache
			const value = ldClient.variation(flagKey, defaultValue) as T
			flagCache.set(flagKey, { timestamp: now, value })
			setFlagValue(value)
		}
	}, [ldClient, flagKey, defaultValue])

	return flagValue
}

Describe alternatives you've considered
I've gone with the cached method in my own codebase. I'm creating this for awareness on the event issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions