DEV Community

Harry Phung
Harry Phung

Posted on Originally published at blog.quangpv.top on

Realtime Config Updates for Multiple Instances with Redis Pub/Sub

Realtime Config Updates for Multiple Instances with Redis Pub/Sub

In this article, I want to share one way to handle dynamic configuration updates with Redis Pub/Sub.

But before getting into the implementation, I think it is worth stepping back and asking a more basic question:

When configuration can change while the system is running, where should the application read that configuration from?

There are a few common approaches, and each comes with its own trade-offs.

Common Ways to Handle Dynamic Configuration

When configuration can change at runtime, one natural approach is to store it in a database or Redis DB.

For example, whenever the application needs to know whether it should send notifications to Slack or Telegram, it reads configuration from a DBMS, Redis DB, or a config service:

{
  telegram: true,
  slack: false,
}

Enter fullscreen mode Exit fullscreen mode

The clear advantage of this approach is that the application always reads the latest value.

The downside is that every request, or every time some piece of logic runs, the application has to hit the DBMS or Redis to fetch configuration. If that configuration is read frequently but changes rarely, a relatively static piece of data becomes a repeated access point in the system.

Another common approach is:

  1. Read the configuration once when the application starts.
  2. Keep it in memory inside each instance.
  3. When the configuration changes, update the source of truth and restart the instances so they reload the new value.

This avoids touching the database or Redis on every request. But once the number of instances grows, restarting stops being an invisible operation. It becomes part of the operational workflow:

  • restart in the right order,
  • make sure no instance is missed,
  • accept a delay before the whole system becomes consistent again.

Another Approach: Keep Config in Memory and Use Redis Pub/Sub for Realtime Updates

Instead of reading from the DB or Redis every time configuration is needed, I can keep the configuration in memory inside each instance for fast access.

The remaining question is: when an admin updates the configuration, how do all running instances know that they should refresh their local copy?

This is where Redis Pub/Sub is useful.

Instead of making every instance poll for changes, I let them listen for a shared event.

The flow is simple:

Admin updates config
        |
        v
Publish event to Redis channel
        |
        v
All subscribed instances receive the event
        |
        v
Each instance updates its in-memory config

Enter fullscreen mode Exit fullscreen mode

The benefits are:

  • no application restart required,
  • no need to hit the DBMS or Redis DB every time configuration is read,
  • no continuous polling,
  • multiple instances can receive the update at nearly the same time,
  • in-memory configuration can be refreshed immediately after an event arrives.

Redis Pub/Sub is not the source of truth for configuration here.

It acts more like a bell: “the config changed, refresh your local copy.”

Example with NestJS

Suppose the system can send notifications to Telegram and Slack.

Realtime Config Updates for Multiple Instances with Redis Pub/Sub

The initial configuration may look like this:

{
  telegram: true,
  slack: true,
}

Enter fullscreen mode Exit fullscreen mode

Later, an admin wants to disable Slack notifications while keeping Telegram enabled:

{
  telegram: true,
  slack: false,
}

Enter fullscreen mode Exit fullscreen mode

In practice, I usually do not create a separate Redis client for every service.

Redis should be treated as shared application infrastructure, so multiple features can reuse the same publisher and listener:

  • one global RedisPublisher to publish events,
  • one global RedisListener to subscribe and dispatch messages to interested modules.

1. Global Redis Publisher and Listener

import { Injectable, OnModuleInit } from '@nestjs/common';
import Redis from 'ioredis';
import { EventEmitter } from 'node:events';

export const RUNTIME_CONFIG_CHANNEL = 'runtime-config';

@Injectable()
export class RedisPublisher {
  private readonly client = new Redis(process.env.REDIS_URL);

  async publish(channel: string, payload: unknown) {
    await this.client.publish(channel, JSON.stringify(payload));
  }
}

@Injectable()
export class RedisListener implements OnModuleInit {
  private readonly client = new Redis(process.env.REDIS_URL);
  private readonly emitter = new EventEmitter();

  async onModuleInit() {
    await this.client.subscribe(RUNTIME_CONFIG_CHANNEL);

    this.client.on('message', (channel, message) => {
      this.emitter.emit(channel, JSON.parse(message));
    });
  }

  on(channel: string, handler: (payload: unknown) => void) {
    this.emitter.on(channel, handler);
  }
}

Enter fullscreen mode Exit fullscreen mode

2. API for Updating Notification Configuration

Here I use the key notification.channels, because this configuration belongs specifically to the notification feature.

import { Body, Controller, Put } from '@nestjs/common';

type NotificationChannelsConfig = {
  telegram: boolean;
  slack: boolean;
};

@Controller('admin/notification-config')
export class NotificationConfigController {
  constructor(private readonly redisPublisher: RedisPublisher) {}

  @Put()
  async update(@Body() payload: NotificationChannelsConfig) {
    await this.redisPublisher.publish(RUNTIME_CONFIG_CHANNEL, {
      key: 'notification.channels',
      value: payload,
    });

    return { success: true };
  }
}

Enter fullscreen mode Exit fullscreen mode

When the admin calls the API with this payload:

{
  telegram: true,
  slack: false,
}

Enter fullscreen mode Exit fullscreen mode

the published message carries the updated configuration.

3. NotificationService Keeps Config in Memory

NotificationService has a config property.

Its default value is loaded from .env. When the module initializes, the service registers with the global Redis listener and only reacts to the key it cares about.

import { Injectable, OnModuleInit } from '@nestjs/common';

type NotificationChannelsConfig = {
  telegram: boolean;
  slack: boolean;
};

@Injectable()
export class NotificationService implements OnModuleInit {
  private config: NotificationChannelsConfig = {
    telegram: process.env.NOTIFY_TELEGRAM === 'true',
    slack: process.env.NOTIFY_SLACK === 'true',
  };

  constructor(private readonly redisListener: RedisListener) {}

  onModuleInit() {
    this.redisListener.on(RUNTIME_CONFIG_CHANNEL, (payload) => {
      const message = payload as {
        key: string;
        value: NotificationChannelsConfig;
      };

      if (message.key === 'notification.channels') {
        this.config = message.value;
      }
    });
  }

  getConfig() {
    return this.config;
  }

  async notify(message: string) {
    const config = this.getConfig();

    if (config.telegram) {
      await this.sendToTelegram(message);
    }

    if (config.slack) {
      await this.sendToSlack(message);
    }
  }

  private async sendToTelegram(message: string) {
    // send telegram message
  }

  private async sendToSlack(message: string) {
    // send slack message
  }
}

Enter fullscreen mode Exit fullscreen mode

The key point is that notify() does not need to query the database or Redis every time it runs.

It only calls getConfig() to read the configuration already available in the instance's memory.

When the admin updates the configuration through the API:

{
  telegram: true,
  slack: false,
}

Enter fullscreen mode Exit fullscreen mode

all running instances receive the event, update this.config, and from the next request onward, notify() sends only to Telegram without requiring an application restart.

An Important Note Before Going to Production

  • Redis publishers and subscribers can lose their connection, reconnect slowly, or be temporarily offline at the exact moment a message is published.

So in a real system, I would still want:

  • automatic reconnect and retry logic for both publishers and subscribers,
  • logs and metrics to know when Redis connections drop,
  • a config source of truth such as a database, Redis key-value storage, or a config service so instances can load the correct configuration on startup or reconnect.

Also, plain Redis Pub/Sub follows a fire-and-forget model.

It does not provide ACKs or retry delivery automatically when a subscriber misses a message.

If the configuration needs stronger guarantees, I can add an ACK mechanism:

  1. Each event carries an eventId or version.
  2. After applying the update, the subscriber sends back an ACK for the applied version.
  3. If the publisher does not receive enough ACKs within a time window, it can retry or reconcile the state again.
publish config.updated(version=42)
        |
        v
subscriber updates this.config
        |
        v
subscriber ACKs version=42
        |
        v
if ACK is missing -> retry / reconcile state

Enter fullscreen mode Exit fullscreen mode

In short:

Pub/Sub helps with realtime updates. Reconnects, ACKs, and retries are what make the system safer to run in production.

Conclusion

If a configuration value is read very often but changes only occasionally, keeping it in memory and using Redis Pub/Sub to synchronize updates across instances can be an efficient approach:

  • fewer DB/Redis reads,
  • no need to restart the application every time configuration changes,
  • near-realtime updates across instances.

The trade-off is that I also need to manage an additional moving part in the system: Redis Pub/Sub itself, along with reconnect, retry, and ACK mechanisms when stronger reliability is required.

I hope you found this article useful.

Top comments (0)