Remote Configuration in Node.js: From Basics to Production

Hardcoding configuration values works—until you need to change them in production at 2am. In this tutorial, we’ll learn how to implement remote configuration in Node.js, starting from a simple polling approach and working up to production-ready solutions.

Похожее
The Kill Switch Pattern: Instant Feature Disabling
Learn how to implement kill switches to instantly disable features in production. Covers design patterns, implementation in Node.js, and real-world examples.
go

The problem with static configuration

Most Node.js applications start with configuration like this:

// Read once at startup
const RATE_LIMIT = parseInt(process.env.RATE_LIMIT ?? '100', 10)
const FEATURE_NEW_UI = process.env.FEATURE_NEW_UI === 'true'
const CACHE_TTL = parseInt(process.env.CACHE_TTL ?? '3600', 10)

This works fine until you need to:

  • Change a rate limit during a traffic spike
  • Disable a broken feature immediately
  • Roll out a feature to 10% of users

All of these require changing environment variables and redeploying. With remote configuration, changes take effect in seconds.

Approach 1: Simple polling

The simplest way to implement remote config is polling—fetching configuration from a server at regular intervals.

interface Config {
  [key: string]: unknown
}

class RemoteConfig {
  private url: string
  private pollInterval: number
  private config: Config = {}
  private running = false

  constructor(url: string, pollInterval = 30000) {
    this.url = url
    this.pollInterval = pollInterval
  }

  async start(): Promise<void> {
    this.running = true
    await this.fetch() // Initial fetch

    // Start background polling
    this.pollLoop()
  }

  stop(): void {
    this.running = false
  }

  get<T>(key: string, defaultValue?: T): T {
    return (this.config[key] as T) ?? defaultValue!
  }

  private async fetch(): Promise<void> {
    try {
      const response = await fetch(this.url)
      if (!response.ok) throw new Error(`HTTP ${response.status}`)

      this.config = await response.json()
      console.log(`Config updated: ${Object.keys(this.config).length} keys`)
    } catch (error) {
      console.error('Failed to fetch config:', error)
    }
  }

  private async pollLoop(): Promise<void> {
    while (this.running) {
      await new Promise((resolve) => setTimeout(resolve, this.pollInterval))
      if (this.running) {
        await this.fetch()
      }
    }
  }
}

Using it:

const config = new RemoteConfig('https://api.example.com/config')
await config.start()

// Later in your code
const rateLimit = config.get('rate-limit', 100)
const newUiEnabled = config.get('new-ui-enabled', false)

This approach has tradeoffs:

| Pros | Cons | |------|------| | Simple to implement | Updates delayed by poll interval | | Works with any HTTP server | Wastes resources when config rarely changes | | Easy to debug | Each instance polls independently |

In the polling approach, what happens if the config server is unreachable?

The application crashes
All config values become undefined
The last successfully fetched config is used
The application restarts automatically

Approach 2: Server-Sent Events (SSE)

For real-time updates, Server-Sent Events are better than polling. The client maintains a persistent connection, and the server pushes updates as they happen.

import { EventSource } from 'eventsource'

type Callback<T> = (value: T) => void

interface Config {
  [key: string]: unknown
}

class SSEConfig {
  private url: string
  private config: Config = {}
  private subscribers: Map<string, Callback<unknown>[]> = new Map()
  private eventSource: EventSource | null = null

  constructor(url: string) {
    this.url = url
  }

  connect(): void {
    this.eventSource = new EventSource(this.url)

    this.eventSource.addEventListener('initial', (event) => {
      this.config = JSON.parse(event.data)
      console.log(`Initial config loaded: ${Object.keys(this.config).length} keys`)
    })

    this.eventSource.addEventListener('config_update', (event) => {
      const { key, value } = JSON.parse(event.data)
      this.handleUpdate(key, value)
    })

    this.eventSource.addEventListener('error', (error) => {
      console.error('SSE connection error:', error)
    })
  }

  disconnect(): void {
    this.eventSource?.close()
    this.eventSource = null
  }

  get<T>(key: string, defaultValue?: T): T {
    return (this.config[key] as T) ?? defaultValue!
  }

  subscribe<T>(key: string, callback: Callback<T>): () => void {
    const callbacks = this.subscribers.get(key) ?? []
    callbacks.push(callback as Callback<unknown>)
    this.subscribers.set(key, callbacks)

    // Return unsubscribe function
    return () => {
      const cbs = this.subscribers.get(key) ?? []
      const index = cbs.indexOf(callback as Callback<unknown>)
      if (index > -1) cbs.splice(index, 1)
    }
  }

  private handleUpdate(key: string, value: unknown): void {
    this.config[key] = value
    console.log(`Config changed: ${key} = ${value}`)

    // Notify subscribers
    const callbacks = this.subscribers.get(key) ?? []
    for (const callback of callbacks) {
      try {
        callback(value)
      } catch (error) {
        console.error('Subscriber error:', error)
      }
    }
  }
}

Usage with subscriptions:

const config = new SSEConfig('https://api.example.com/config/stream')
config.connect()

// React to changes
config.subscribe<number>('rate-limit', (newValue) => {
  console.log(`Rate limit changed to ${newValue}`)
  rateLimiter.updateLimit(newValue)
})

With SSE, updates arrive in milliseconds rather than waiting for the next poll.

Похожее
string::substr in C++: Extracting Parts of Strings
C++ offers various methods to work with strings. Among them, the substr function stands out as a way to extract specific portions of a string. In this article, we'll dive into the substr function, see how it works, and explore its numerous applications.
go

Approach 3: Using a production SDK

For production use, it’s better to use a battle-tested SDK rather than building your own. Here’s an example using the Replane JavaScript SDK:

First, install it:

npm install @replanejs/sdk

Then use it in your code:

import { Replane } from '@replanejs/sdk'

// Define your config types
interface Configs {
  'rate-limit': number
  'new-ui-enabled': boolean
  'cache-ttl': number
}

// Create client with defaults (used if server is unreachable)
const replane = new Replane<Configs>({
  defaults: {
    'rate-limit': 100,
    'new-ui-enabled': false,
    'cache-ttl': 3600,
  },
})

// Connect to the server
await replane.connect({
  sdkKey: process.env.REPLANE_SDK_KEY!,
  baseUrl: 'https://cloud.replane.dev',
})

// Get config values - typed!
const rateLimit = replane.get('rate-limit') // number

// With context for user-specific overrides
const userRateLimit = replane.get('rate-limit', {
  context: {
    userId: user.id,
    plan: user.subscription,
  },
})

// Subscribe to changes
replane.subscribe('rate-limit', (config) => {
  console.log(`Rate limit changed: ${config.value}`)
})

// When shutting down
replane.disconnect()

The SDK handles:

  • SSE connection with automatic reconnection
  • Local caching for instant reads
  • Context-based override evaluation
  • Graceful fallback to defaults

Why use a production SDK instead of building your own polling client?

SDKs are always faster
SDKs use less memory
SDKs handle edge cases like reconnection and caching
SDKs are required by law

Integration with Express

Here’s how to integrate remote config with an Express application:

import express from 'express'
import { Replane } from '@replanejs/sdk'

interface Configs {
  'rate-limit': number
}

const app = express()

// Initialize once at startup
const replane = new Replane<Configs>({
  defaults: { 'rate-limit': 100 },
})

await replane.connect({
  sdkKey: process.env.REPLANE_SDK_KEY!,
  baseUrl: 'https://cloud.replane.dev',
})

// Make config available in request handlers
app.get('/api/data', (req, res) => {
  const rateLimit = replane.get('rate-limit')
  // Use rateLimit for throttling...
  res.json({ data: 'hello' })
})

// Cleanup on shutdown
process.on('SIGTERM', () => {
  replane.disconnect()
  process.exit(0)
})

app.listen(3000)

Integration with Fastify

For Fastify, you can use a plugin pattern:

import Fastify from 'fastify'
import { Replane } from '@replanejs/sdk'

interface Configs {
  'rate-limit': number
}

const fastify = Fastify()

// Initialize config
const replane = new Replane<Configs>({
  defaults: { 'rate-limit': 100 },
})

await replane.connect({
  sdkKey: process.env.REPLANE_SDK_KEY!,
  baseUrl: 'https://cloud.replane.dev',
})

// Decorate fastify with config access
fastify.decorate('config', replane)

fastify.get('/api/data', async (request, reply) => {
  const rateLimit = fastify.config.get('rate-limit')
  return { data: 'hello', rateLimit }
})

// Cleanup hook
fastify.addHook('onClose', async () => {
  replane.disconnect()
})

await fastify.listen({ port: 3000 })
Похожее
vector::push_back in C++: Adding Elements to a Vector
C++ offers various ways to manipulate data. The push_back function is a popular way to add elements to a vector. In this article, we'll delve deep into this function, exploring how and when to use it, and discuss some intriguing aspects related to it.
go

Best practices

When implementing remote configuration in Node.js:

Always set defaults. Your application should work even if the config server is down:

// Good: has default
const rateLimit = replane.get('rate-limit', { default: 100 })

// Bad: throws if key missing
const rateLimit = replane.get('rate-limit')

Don’t fetch on every request. Cache config values in memory and update via SSE or polling:

// Bad: network call on every request
app.get('/api', async (req, res) => {
  const config = await fetch('https://config.example.com').then(r => r.json())
})

// Good: read from cache
app.get('/api', (req, res) => {
  const rateLimit = replane.get('rate-limit')
})

Log config changes. When debugging, you need to know what config was active:

replane.subscribe('rate-limit', (config) => {
  logger.info(`Config changed: rate-limit=${config.value}`)
})

Keep secrets separate. Remote config is for feature flags and operational settings, not for API keys:

// Good: secrets from environment
const apiKey = process.env.STRIPE_API_KEY

// Good: feature flags from remote config
const stripeEnabled = replane.get('stripe-enabled')

Summary

Remote configuration lets you change Node.js application behavior without redeploying. We covered three approaches:

  1. Polling — Simple but has latency
  2. SSE — Real-time but more complex
  3. Production SDK — Best for real applications

For production use, consider using Replane (open-source, self-hosted) or similar tools rather than building from scratch.

Exercises

  1. Build a polling client: Extend the RemoteConfig class to support callbacks when specific keys change.

  2. Add caching: Modify the polling client to persist config to disk, so the application can start even if the config server is unreachable.

  3. Implement context-based overrides: Add a feature where different users get different config values based on their user ID or subscription plan.

Discussion

© 2026, codelessons.dev