Skip to content
ExponaBLOG
KAFKA & WEBSOCKETS · DAY 067 / 100

Typing indicators without flooding in Kafka & WebSockets

Tiny feature, surprising traffic.

Expona Admin9 min read

Typing indicators without flooding

You've built a real-time chat or collaboration feature, and users expect to see "Person is typing…" indicators the moment someone presses a key. The problem? Broadcasting a Kafka message on every keystroke generates staggering traffic—thousands of events per minute from a single user typing naturally. I've seen production systems where typing indicators alone consumed more bandwidth than the actual messages being sent. In this article, I'll show you how to implement typing indicators efficiently by sending only start and stop events, throttling aggressively, and expiring state automatically.

Why typing indicators create surprising traffic

Let's do quick math. A typical typist produces 40–60 words per minute, which translates to roughly 4–6 keystrokes per second. If you emit a Kafka event on every keystroke in a group chat with 20 people, that's 24–36 events per second per user. Multiply that across even a modest deployment with 100 concurrent chatters, and you're pushing 2,400–3,600 events per second for a feature that doesn't affect business logic directly.

Most developers discover this in production when they see Kafka lag spike or consumer lag alerts fire. The cost isn't just infrastructure; it's real-time event routing, consumer group scaling, and downstream processing. I learned this lesson the hard way during a ride-hailing platform upgrade where driver "is searching for passengers" indicators flooded our event stream.

The solution is counterintuitive: send fewer events, not smarter filtering. We don't need granular keystroke data; we need to signal state transitions: "Person started typing" and "Person stopped typing."

Send start and stop events, not every keystroke

Instead of emitting on each keystroke, we emit only when the typing state changes. A user is either typing or not typing. That's a binary state, and state changes are sparse compared to keystroke frequency.

Here's the core logic:

typescript
interface TypingState {
  isTyping: boolean;
  lastKeystrokeAt: number;
}

class TypingIndicatorEmitter {
  private state: Map<string, TypingState> = new Map();
  private typingThreshold = 3000; // 3 seconds

  onKeystroke(userId: string, roomId: string): void {
    const now = Date.now();
    const currentState = this.state.get(userId) || { isTyping: false, lastKeystrokeAt: 0 };

    // First keystroke after idle period: emit START
    if (!currentState.isTyping) {
      this.emitTypingEvent(userId, roomId, 'started');
      this.state.set(userId, { isTyping: true, lastKeystrokeAt: now });
    } else {
      // Already typing: just update timestamp, don't emit
      currentState.lastKeystrokeAt = now;
    }
  }

  private emitTypingEvent(userId: string, roomId: string, action: 'started' | 'stopped'): void {
    const event = {
      type: 'typing_indicator',
      userId,
      roomId,
      action,
      timestamp: Date.now(),
    };
    // Send to Kafka
    this.kafkaProducer.send('typing-events', JSON.stringify(event));
  }
}

The client calls onKeystroke() whenever the user types. We check if the user was already in a typing state. If they weren't, we emit a started event and mark them as typing. If they were already typing, we do nothing—just update the timestamp.

Later, a background job checks for stale keystrokes. If more than 3 seconds have passed since the last keystroke, we emit a stopped event and reset the state.

typescript
  checkForStaleTyping(): void {
    const now = Date.now();
    for (const [userId, state] of this.state) {
      if (state.isTyping && now - state.lastKeystrokeAt > this.typingThreshold) {
        this.emitTypingEvent(userId, state.roomId, 'stopped');
        state.isTyping = false;
      }
    }
  }
}

Run this check every second via setInterval. This approach drops our event count from thousands per minute to a handful per user session.

Throttle to one event every few seconds

Even with start/stop logic, a user might rapidly switch between typing and not typing (e.g., pausing to think, then resuming). We can further reduce noise by throttling: don't emit more than one event every N seconds from the same user.

Throttling has a cost, though. If you throttle too aggressively, legitimate state transitions get dropped. I typically use 2–5 seconds.

typescript
interface TypingState {
  isTyping: boolean;
  lastKeystrokeAt: number;
  lastEmittedAt: number;
}

class ThrottledTypingEmitter {
  private state: Map<string, TypingState> = new Map();
  private throttleWindow = 5000; // 5 seconds

  onKeystroke(userId: string, roomId: string): void {
    const now = Date.now();
    const currentState = this.state.get(userId) || {
      isTyping: false,
      lastKeystrokeAt: 0,
      lastEmittedAt: 0,
    };

    // Transition from not-typing to typing?
    if (!currentState.isTyping) {
      // Check throttle: have we emitted in the last N seconds?
      if (now - currentState.lastEmittedAt > this.throttleWindow) {
        this.emitTypingEvent(userId, roomId, 'started');
        currentState.lastEmittedAt = now;
      }
      currentState.isTyping = true;
    }

    currentState.lastKeystrokeAt = now;
    this.state.set(userId, currentState);
  }

  private emitTypingEvent(userId: string, roomId: string, action: 'started' | 'stopped'): void {
    const event = {
      type: 'typing_indicator',
      userId,
      roomId,
      action,
      timestamp: Date.now(),
    };
    this.kafkaProducer.send('typing-events', JSON.stringify(event));
  }
}

With throttling, even if a user types continuously, we emit at most once every 5 seconds. The UI still updates in near-real-time because the indicator itself remains on-screen after the first event until it expires.

Expire indicators automatically

Here's the crucial piece: typing indicators must expire on the client side and server side. If a user's browser crashes or network drops mid-keystroke, their indicator could stay visible for hours unless it expires.

Set a TTL (time-to-live) on both sides. On the client, a simple timer works:

typescript
class TypingIndicatorUI {
  private typingUsers: Map<string, ReturnType<typeof setTimeout>> = new Map();
  private typingExpiryMs = 5000; // 5 seconds

  onTypingEvent(event: { userId: string; action: 'started' | 'stopped' }): void {
    if (event.action === 'started') {
      // Cancel any existing timer for this user
      if (this.typingUsers.has(event.userId)) {
        clearTimeout(this.typingUsers.get(event.userId)!);
      }

      // Show indicator
      this.showTypingIndicator(event.userId);

      // Set expiry timer
      const timer = setTimeout(() => {
        this.hideTypingIndicator(event.userId);
        this.typingUsers.delete(event.userId);
      }, this.typingExpiryMs);

      this.typingUsers.set(event.userId, timer);
    } else if (event.action === 'stopped') {
      // Explicit stop: clear immediately
      if (this.typingUsers.has(event.userId)) {
        clearTimeout(this.typingUsers.get(event.userId)!);
        this.hideTypingIndicator(event.userId);
        this.typingUsers.delete(event.userId);
      }
    }
  }

  private showTypingIndicator(userId: string): void {
    // Update UI: append to "typing" list
  }

  private hideTypingIndicator(userId: string): void {
    // Update UI: remove from "typing" list
  }
}

On the server, if you're broadcasting to other users, also enforce expiry on received indicators. Don't rely on the sender's stop event; if it doesn't arrive, the timer ensures cleanup.

Don't persist them

This is the mistake I see most often: developers store typing state in a relational database or Redis as if it were durable application state. It isn't. Typing indicators are ephemeral signals, not facts about the world.

If you must store state, use Redis with a short TTL or an in-memory map, never a database. Here's why:

  • Database writes are slow. A single write per keystroke event group multiplied across users creates latency spikes.
  • Queries are expensive. Fetching "who is typing in this room" from a database on every broadcast is wasteful.
  • Stale data persists. If the server restarts, old typing state lingers until you manually clean it up.
  • Replication overhead. If you replicate across regions, typing state bloats your bandwidth for zero value.

Use a simple in-memory store with built-in expiry:

typescript
class TypingIndicatorService {
  private typingUsers: Map<string, { roomId: string; expiresAt: number }> = new Map();
  private roomTypingIndex: Map<string, Set<string>> = new Map();
  private expiryMs = 5000;

  recordTyping(userId: string, roomId: string): void {
    const key = `${userId}:${roomId}`;
    this.typingUsers.set(key, {
      roomId,
      expiresAt: Date.now() + this.expiryMs,
    });

    if (!this.roomTypingIndex.has(roomId)) {
      this.roomTypingIndex.set(roomId, new Set());
    }
    this.roomTypingIndex.get(roomId)!.add(userId);
  }

  getTypingUsersInRoom(roomId: string): string[] {
    const now = Date.now();
    const users = this.roomTypingIndex.get(roomId) || new Set();
    const active: string[] = [];

    for (const userId of users) {
      const key = `${userId}:${roomId}`;
      const state = this.typingUsers.get(key);
      if (state && state.expiresAt > now) {
        active.push(userId);
      } else {
        users.delete(userId);
        this.typingUsers.delete(key);
      }
    }

    return active;
  }

  stopTyping(userId: string, roomId: string): void {
    const key = `${userId}:${roomId}`;
    this.typingUsers.delete(key);
    this.roomTypingIndex.get(roomId)?.delete(userId);
  }
}

Keep everything in memory. When a room empties or the server shuts down, the data evaporates—which is exactly what we want. On server restart, users see no ghosts from the previous session.

Common mistakes

  • Emitting on every keystroke: I've seen codebases that do this without throttling. Result: Kafka broker CPU spikes, consumer lag, and chaos. Always implement state-based events.
  • Forgetting the stop event: A crashed client leaves the indicator on-screen forever unless you emit a stop or enforce expiry. Always have a timeout fallback.
  • Storing in a database: Typing indicators in PostgreSQL or MySQL turn a simple feature into a scaling nightmare. Keep them transient.
  • Not indexing by room: If you query "who is typing?" by iterating all users, latency grows linearly. Use a secondary index (room → user set) for fast lookups.
  • Ignoring Kafka ordering guarantees: If events arrive out of order (e.g., stopped before started), your logic breaks. Use partition keys carefully or add sequence numbers.
  • Broadcasting to too many subscribers: If you emit typing events to a Kafka topic with hundreds of consumers, you've just multiplied your throughput. Filter early, or use a room-specific topic/channel.

Key takeaways

  • Send only start and stop events, not keystroke-level events. This cuts traffic by 99% compared to naive implementations.
  • Throttle events to one per N seconds (2–5 seconds typically works) to reduce redundant broadcasts.
  • Expire indicators on both client and server. Use timers on the client and TTL on the server to ensure ghosts never linger.
  • Keep typing state in memory or Redis, never in a database. It's ephemeral signal, not persistent data.
  • Index state by room for efficient queries. You need to answer "who is typing in this room?" in constant time.
  • Test network loss and server restarts. Typing indicators should vanish gracefully, not haunt users.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles