Last Friday, our API got hammered during a flash sale. Rate limit was set to 100 requests/second. We needed 50. Our deploy pipeline? 12 minutes.
That's when I realized: environment variables are static configuration. They change when you redeploy. But some values need to change now.
The Problem I Kept Hitting
Most Node.js apps start like this:
const config = {
rateLimit: parseInt(process.env.RATE_LIMIT || "100"),
featureNewCheckout: process.env.FEATURE_NEW_CHECKOUT === "true",
cacheMaxAge: parseInt(process.env.CACHE_MAX_AGE || "3600"),
};
Fine for database URLs and API keys. But for these? Pain:
- Rate limits during traffic spikes
- Feature flags for gradual rollouts
- Timeouts when downstream services are slow
- Kill switches for broken features
Every change = PR → review → merge → CI → deploy → pray.
What Dynamic Configuration Actually Is
Three properties make config "dynamic":
- Changes propagate without restarts. Update a value, running instances get it in seconds.
- Values are evaluated at read time. Not cached forever at startup.
- History is preserved. Who changed what, when. Instant rollback if needed.
Three Ways to Implement It
1. Polling (Simplest)
Fetch config from an external store on a timer:
let config: Config = JSON.parse(readFileSync("./config.json", "utf-8"));
setInterval(async () => {
try {
const response = await fetch("https://config-api.internal/v1/config");
config = await response.json();
} catch (error) {
console.error("Failed to refresh config:", error);
// Keep using last known good config
}
}, 30_000);
Pros: Works with anything (Redis, DB, HTTP API).
Cons: Updates delayed by polling interval. Wasteful if config rarely changes.
2. Webhooks (Push-based)
Config server pushes updates when they happen:
app.post("/config-webhook", express.json(), (req, res) => {
const { secret, payload } = req.body;
if (secret !== process.env.WEBHOOK_SECRET) {
return res.status(401).json({ error: "Invalid secret" });
}
config = payload;
res.json({ ok: true });
});
Pros: Faster updates.
Cons: Need to expose endpoint, handle auth, ensure all instances get the update.
3. Server-Sent Events (Best of Both)
Persistent connection, server streams updates:
import { EventSource } from "eventsource";
const source = new EventSource("https://config-api.internal/v1/stream", {
headers: { Authorization: `Bearer ${process.env.CONFIG_API_KEY}` },
});
source.addEventListener("config_change", (event) => {
const change = JSON.parse(event.data);
config = { ...config, [change.name]: change.value };
});
Pros: Real-time updates (<100ms). Auto-reconnection. No inbound endpoints.
Cons: Needs infrastructure that supports long-lived connections.
Beyond Key-Value: What Real Apps Need
Simple get(key) isn't enough. You also need:
-
Type safety — TypeScript should know
rateLimitis a number - Context-aware values — Different rate limits for premium vs free users
- Defaults — Keep running if config server is down
- Subscriptions — React when specific values change
Here's what this looks like with Replane (open-source, SSE-based):
import { Replane } from "@replanejs/sdk";
interface Configs {
"api-rate-limit": number;
"feature-new-checkout": boolean;
"cache-settings": { maxAge: number; staleWhileRevalidate: number };
}
const replane = new Replane<Configs>({
defaults: {
"api-rate-limit": 100,
"feature-new-checkout": false,
"cache-settings": { maxAge: 3600, staleWhileRevalidate: 60 },
},
});
await replane.connect({
sdkKey: process.env.REPLANE_SDK_KEY!,
baseUrl: "https://cloud.replane.dev",
});
// Type-safe
const rateLimit = replane.get("api-rate-limit");
// Context-aware
const userRateLimit = replane.get("api-rate-limit", {
context: { userId: user.id, plan: user.subscription },
});
// React to changes
replane.subscribe("cache-settings", (config) => {
cacheManager.configure(config.value);
});
When to Use Dynamic Config
Good candidates:
| Use Case | Example | Why Dynamic |
|---|---|---|
| Feature flags | new-checkout-enabled |
Roll out to 1% → 10% → 100% |
| Rate limits | api-rate-limit |
Respond to traffic spikes |
| Timeouts | downstream-timeout-ms |
Adjust for slow services |
| Kill switches | payments-enabled |
Disable broken features instantly |
Keep as env vars:
- Database connection strings
- API keys and secrets
- Service URLs
- Log destinations
These don't change while running (and shouldn't).
Common Mistakes
- Putting secrets in dynamic config — Use a secrets manager instead.
- Using it for everything — DB URLs don't need instant updates.
- No defaults — App should start even if config server is down.
- Fetching on every request — Cache in memory, update via SSE/polling.
- No validation — A typo shouldn't crash production.
Getting Started
- Identify candidates — What do you wish you could change without deploying?
- Set up a backend — Start simple (JSON file, Redis) or use something like Replane.
- Add defaults — Always have fallbacks.
- Migrate incrementally — Start with one value, expand from there.
- Monitor config changes — Know when changes happen.
If you're building something similar or have questions, drop a comment. Been running this in production for a while now and happy to share what worked (and what didn't).
Top comments (0)