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 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?
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.
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/sdkThen 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?
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 })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:
- Polling — Simple but has latency
- SSE — Real-time but more complex
- 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
-
Build a polling client: Extend the
RemoteConfigclass to support callbacks when specific keys change. -
Add caching: Modify the polling client to persist config to disk, so the application can start even if the config server is unreachable.
-
Implement context-based overrides: Add a feature where different users get different config values based on their user ID or subscription plan.
Discussion