DEV Community

Kashaf Abdullah
Kashaf Abdullah

Posted on

How WhatsApp Shows "Typing..." Instantly: The Magic Behind Real-time Indicators

Introduction

Have you ever wondered how WhatsApp shows "typing..." the moment someone starts typing? Is it sending a request for every single key press? The answer might surprise you! In this deep dive, we'll explore the fascinating world of real-time communication and learn how to build our own WhatsApp-like typing indicator using the MERN stack.


How WhatsApp Actually Works

The Illusion of Instantaneity

WhatsApp's typing indicator feels instant, but it's actually a clever optimization. When you start typing:

  1. First key press: WhatsApp waits a tiny moment (about 300ms)
  2. Sends "typing started" signal (not on every key press!)
  3. Continuous typing: No additional signals needed
  4. Pause detected: Waits 1–2 seconds, then sends "typing stopped"
  5. Message sent: Immediately sends "stopping typing" signal

Why Not Send Every Keystroke?

Sending a request for every key press would be incredibly inefficient:

  • Network overload: Too many requests
  • Battery drain: Constant network activity
  • Server load: Unnecessary processing
  • Costly: More data usage

The Secret Sauce: Debouncing

What is Debouncing?

Debouncing is a programming technique that limits the rate at which a function is executed. Think of it like a thoughtful friend who waits for you to finish speaking before responding, rather than interrupting after every word.

Simple Analogy

Imagine an elevator:

  • People keep pressing the button
  • Elevator doesn't move with every press
  • Waits for a pause (debounce period)
  • Then moves efficiently

JavaScript Debounce Function

function debounce(func, delay) {
  let timeoutId;

  return function(...args) {
    // Cancel the previous timeout
    clearTimeout(timeoutId);

    // Set a new timeout
    timeoutId = setTimeout(() => {
      func.apply(this, args);
    }, delay);
  };
}
Enter fullscreen mode Exit fullscreen mode

Building Our Own WhatsApp Clone

Let's create a complete MERN stack application that demonstrates typing indicators with proper debouncing.

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                   REAL-TIME CHAT ARCHITECTURE                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌──────────────┐     ┌─────────────────┐     ┌──────────┐ │
│  │  CLIENT 1    │     │                 │     │ CLIENT 2 │ │
│  │  (User A)    │     │  WEBSOCKET      │     │ (User B) │ │
│  │              │     │    SERVER       │     │          │ │
│  │ ┌──────────┐ │     │ ┌─────────────┐ │     │          │ │
│  │ │ React    │ │     │ │ Node.js/    │ │     │          │ │
│  │ │ App      │ │◄───►│ │ Express     │ │◄───►│ React    │ │
│  │ │          │ │     │ │ Socket.io   │ │     │ App      │ │
│  │ └──────────┘ │     │ └─────────────┘ │     │          │ │
│  │     ▲        │     │        ▲        │     │          │ │
│  │     │        │     │        │        │     │          │ │
│  │ Typing Event │     │  Broadcast to   │     │ Typing   │ │
│  │              │     │   Other Clients │     │ Indicator│ │
│  └──────────────┘     └─────────────────┘     └──────────┘ │
│          │                          │                      │
│          └──────────────────────────┘                      │
│                 WebSocket Connection                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Flow:

  1. User A types → React detects input
  2. Send "typing" event via WebSocket
  3. Server receives and validates
  4. Broadcast to all other clients in chat
  5. User B sees "User A is typing..."
  6. User A stops typing → Send "stopped" event
  7. Server broadcasts removal of indicator

1. Backend Implementation

const express = require('express');
const http = require('http');
const socketIo = require('socket.io');
const cors = require('cors');

const app = express();
app.use(cors());

const server = http.createServer(app);
const io = socketIo(server, {
  cors: { origin: "http://localhost:3000" }
});

// Store active typing users
const typingStatus = new Map();

io.on('connection', (socket) => {
  console.log('🔗 New connection:', socket.id);

  // Join a specific chat room
  socket.on('join_chat', ({ chatId, userId }) => {
    socket.join(chatId);
    console.log(`👤 ${userId} joined ${chatId}`);
  });

  // Handle typing indicators with debouncing logic
  socket.on('typing_event', ({ chatId, userId, isTyping }) => {
    const key = `${chatId}_${userId}`;

    if (isTyping) {
      // User started typing
      typingStatus.set(key, true);
      socket.to(chatId).emit('typing_indicator', {
        userId,
        isTyping: true,
        timestamp: Date.now()
      });
    } else {
      // User stopped typing
      if (typingStatus.has(key)) {
        typingStatus.delete(key);
        socket.to(chatId).emit('typing_indicator', {
          userId,
          isTyping: false,
          timestamp: Date.now()
        });
      }
    }
  });

  // Handle messages
  socket.on('send_message', ({ chatId, userId, message }) => {
    // Clear typing status
    const key = `${chatId}_${userId}`;
    if (typingStatus.has(key)) {
      typingStatus.delete(key);
      socket.to(chatId).emit('typing_indicator', {
        userId,
        isTyping: false
      });
    }

    // Broadcast message
    io.to(chatId).emit('new_message', {
      userId,
      message,
      timestamp: new Date(),
      chatId
    });
  });

  // Cleanup on disconnect
  socket.on('disconnect', () => {
    console.log('🔌 Disconnected:', socket.id);
  });
});

const PORT = 5000;
server.listen(PORT, () => {
  console.log(`🚀 Server running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

2. Frontend Implementation

import React, { useState, useEffect, useRef, useCallback } from 'react';
import io from 'socket.io-client';
import './App.css';

const socket = io('http://localhost:5000');

function App() {
  const [message, setMessage] = useState('');
  const [messages, setMessages] = useState([]);
  const [typingUsers, setTypingUsers] = useState(new Set());
  const [userId] = useState(`user_${Date.now()}`);
  const [chatId] = useState('chat_123');

  // Refs for debouncing
  const typingTimeoutRef = useRef(null);
  const isTypingRef = useRef(false);

  useEffect(() => {
    // Join chat room
    socket.emit('join_chat', { chatId, userId });

    // Listen for messages
    socket.on('new_message', (data) => {
      setMessages(prev => [...prev, data]);
    });

    // Listen for typing indicators
    socket.on('typing_indicator', (data) => {
      if (data.userId !== userId) {
        if (data.isTyping) {
          setTypingUsers(prev => new Set(prev).add(data.userId));
        } else {
          setTypingUsers(prev => {
            const newSet = new Set(prev);
            newSet.delete(data.userId);
            return newSet;
          });
        }
      }
    });

    return () => {
      socket.off('new_message');
      socket.off('typing_indicator');
    };
  }, [chatId, userId]);

  // Custom debounce hook
  const useDebounce = (callback, delay) => {
    const timeoutRef = useRef(null);

    return useCallback((...args) => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }

      timeoutRef.current = setTimeout(() => {
        callback(...args);
      }, delay);
    }, [callback, delay]);
  };

  // Debounced typing handler
  const debouncedTypingStopped = useDebounce(() => {
    if (isTypingRef.current) {
      socket.emit('typing_event', { chatId, userId, isTyping: false });
      isTypingRef.current = false;
    }
  }, 1500); // 1.5 second delay

  const handleTyping = (e) => {
    const value = e.target.value;
    setMessage(value);

    // User is typing
    if (value.length > 0) {
      if (!isTypingRef.current) {
        // First character - send typing started immediately
        socket.emit('typing_event', { chatId, userId, isTyping: true });
        isTypingRef.current = true;
      }

      // Reset the debounce timer
      debouncedTypingStopped();
    } else {
      // Input is empty
      if (isTypingRef.current) {
        socket.emit('typing_event', { chatId, userId, isTyping: false });
        isTypingRef.current = false;
      }
    }
  };

  const sendMessage = () => {
    if (message.trim()) {
      socket.emit('send_message', { chatId, userId, message });
      setMessage('');

      // Clear typing status
      if (isTypingRef.current) {
        socket.emit('typing_event', { chatId, userId, isTyping: false });
        isTypingRef.current = false;
      }
    }
  };

  return (
    <div className="app-container">
      <header className="app-header">
        <h1>WhatsApp Typing Indicator Demo</h1>
        <p className="subtitle">Understanding real-time communication with debouncing</p>
      </header>

      <div className="content-wrapper">
        <div className="explanation-section">
          <h2>How It Works</h2>
          <div className="workflow">
            <div className="step">
              <div className="step-number">1</div>
              <div className="step-content">
                <h3>First Keystroke</h3>
                <p>Immediate "typing started" signal sent</p>
              </div>
            </div>

            <div className="step">
              <div className="step-number">2</div>
              <div className="step-content">
                <h3>Continuous Typing</h3>
                <p>No additional signals - debouncing in action</p>
              </div>
            </div>

            <div className="step">
              <div className="step-number">3</div>
              <div className="step-content">
                <h3>Pause Detection</h3>
                <p>Waits 1.5 seconds after last keystroke</p>
              </div>
            </div>

            <div className="step">
              <div className="step-number">4</div>
              <div className="step-content">
                <h3>Typing Stopped</h3>
                <p>Sends "typing stopped" signal</p>
              </div>
            </div>
          </div>
        </div>

        <div className="chat-section">
          <div className="chat-container">
            <div className="chat-header">
              <h2>Group Chat</h2>
              <p className="user-id">You: {userId}</p>

              {typingUsers.size > 0 && (
                <div className="active-typing">
                  <span className="typing-text">
                    {Array.from(typingUsers).map(id => 
                      id.replace('user_', 'User ')
                    ).join(', ')}
                    {typingUsers.size === 1 ? ' is typing...' : ' are typing...'}
                  </span>
                  <div className="typing-animation">
                    <div className="dot"></div>
                    <div className="dot"></div>
                    <div className="dot"></div>
                  </div>
                </div>
              )}
            </div>

            <div className="messages-area">
              {messages.map((msg, index) => (
                <div 
                  key={index} 
                  className={`message-bubble ${
                    msg.userId === userId ? 'sent' : 'received'
                  }`}
                >
                  <div className="message-sender">
                    {msg.userId === userId ? 'You' : msg.userId.replace('user_', 'User ')}
                  </div>
                  <div className="message-content">{msg.message}</div>
                  <div className="message-time">
                    {new Date(msg.timestamp).toLocaleTimeString([], {
                      hour: '2-digit',
                      minute: '2-digit'
                    })}
                  </div>
                </div>
              ))}
            </div>

            <div className="input-section">
              <input
                type="text"
                value={message}
                onChange={handleTyping}
                onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
                placeholder="Type a message..."
                className="message-input"
              />
              <button onClick={sendMessage} className="send-button">
                Send
              </button>
            </div>
          </div>

          <div className="debug-panel">
            <h3>Debug Information</h3>
            <div className="debug-info">
              <p><strong>Your Status:</strong> 
                <span className={isTypingRef.current ? 'typing' : 'not-typing'}>
                  {isTypingRef.current ? ' Typing...' : ' Not typing'}
                </span>
              </p>
              <p><strong>Active Typers:</strong> {typingUsers.size}</p>
              <p><strong>Messages Sent:</strong> {messages.length}</p>
              <p><strong>Chat Room:</strong> {chatId}</p>
            </div>
          </div>
        </div>
      </div>

      <footer className="app-footer">
        <p>Built with React, Node.js, and Socket.io | Demonstrating real-time typing indicators with debouncing</p>
      </footer>
    </div>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

3. CSS Styling

/* App.css */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
}

.app-container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.app-header {
  text-align: center;
  color: white;
  margin-bottom: 40px;
  padding: 30px;
  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(10px);
  border-radius: 20px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}

.app-header h1 {
  font-size: 2.5rem;
  margin-bottom: 10px;
  background: linear-gradient(90deg, #fff, #a8edea);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

.subtitle {
  font-size: 1.1rem;
  opacity: 0.9;
}

.content-wrapper {
  display: grid;
  grid-template-columns: 1fr 1.5fr;
  gap: 30px;
  margin-bottom: 40px;
}

.explanation-section {
  background: white;
  border-radius: 20px;
  padding: 30px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}

.explanation-section h2 {
  color: #333;
  margin-bottom: 25px;
  font-size: 1.8rem;
}

.workflow {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.step {
  display: flex;
  align-items: flex-start;
  gap: 15px;
  padding: 20px;
  background: #f8f9fa;
  border-radius: 15px;
  transition: transform 0.3s ease;
}

.step:hover {
  transform: translateX(10px);
  background: #e9ecef;
}

.step-number {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
  font-size: 1.2rem;
  flex-shrink: 0;
}

.step-content h3 {
  color: #333;
  margin-bottom: 5px;
  font-size: 1.1rem;
}

.step-content p {
  color: #666;
  font-size: 0.95rem;
  line-height: 1.5;
}

.chat-section {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.chat-container {
  background: white;
  border-radius: 20px;
  overflow: hidden;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
  display: flex;
  flex-direction: column;
  height: 500px;
}

.chat-header {
  background: linear-gradient(135deg, #075e54 0%, #128c7e 100%);
  color: white;
  padding: 25px;
  position: relative;
}

.chat-header h2 {
  margin-bottom: 10px;
  font-size: 1.6rem;
}

.user-id {
  opacity: 0.9;
  font-size: 0.9rem;
}

.active-typing {
  position: absolute;
  bottom: 15px;
  left: 25px;
  right: 25px;
  background: rgba(255, 255, 255, 0.15);
  padding: 10px 15px;
  border-radius: 10px;
  backdrop-filter: blur(5px);
  display: flex;
  align-items: center;
  justify-content: space-between;
  animation: slideIn 0.3s ease-out;
}

@keyframes slideIn {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.typing-text {
  font-size: 0.9rem;
}

.typing-animation {
  display: flex;
  gap: 4px;
}

.dot {
  width: 8px;
  height: 8px;
  background: white;
  border-radius: 50%;
  animation: typing 1.4s infinite;
}

.dot:nth-child(2) {
  animation-delay: 0.2s;
}

.dot:nth-child(3) {
  animation-delay: 0.4s;
}

@keyframes typing {
  0%, 60%, 100% {
    transform: translateY(0);
  }
  30% {
    transform: translateY(-5px);
  }
}

.messages-area {
  flex: 1;
  padding: 20px;
  overflow-y: auto;
  background: #f0f2f5;
}

.message-bubble {
  max-width: 70%;
  margin-bottom: 15px;
  padding: 12px 16px;
  border-radius: 18px;
  position: relative;
  animation: messageAppear 0.3s ease-out;
}

@keyframes messageAppear {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.message-bubble.sent {
  background: #dcf8c6;
  margin-left: auto;
  border-bottom-right-radius: 5px;
}

.message-bubble.received {
  background: white;
  margin-right: auto;
  border-bottom-left-radius: 5px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}

.message-sender {
  font-size: 0.75rem;
  font-weight: 600;
  color: #075e54;
  margin-bottom: 4px;
}

.message-content {
  font-size: 0.95rem;
  line-height: 1.4;
  margin-bottom: 6px;
}

.message-time {
  font-size: 0.7rem;
  text-align: right;
  color: rgba(0, 0, 0, 0.5);
}

.input-section {
  padding: 20px;
  background: #f0f0f0;
  display: flex;
  gap: 10px;
  border-top: 1px solid #ddd;
}

.message-input {
  flex: 1;
  padding: 14px 20px;
  border: 2px solid #ddd;
  border-radius: 25px;
  font-size: 1rem;
  outline: none;
  transition: all 0.3s;
}

.message-input:focus {
  border-color: #128c7e;
  box-shadow: 0 0 0 3px rgba(18, 140, 126, 0.1);
}

.send-button {
  background: linear-gradient(135deg, #075e54 0%, #128c7e 100%);
  color: white;
  border: none;
  border-radius: 25px;
  padding: 0 30px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.3s;
}

.send-button:hover {
  transform: translateY(-2px);
  box-shadow: 0 5px 15px rgba(7, 94, 84, 0.3);
}

.send-button:active {
  transform: translateY(0);
}

.debug-panel {
  background: white;
  border-radius: 20px;
  padding: 25px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}

.debug-panel h3 {
  color: #333;
  margin-bottom: 20px;
  font-size: 1.3rem;
}

.debug-info {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 15px;
}

.debug-info p {
  padding: 12px;
  background: #f8f9fa;
  border-radius: 10px;
  font-size: 0.9rem;
}

.typing {
  color: #128c7e;
  font-weight: 600;
}

.not-typing {
  color: #666;
}

.app-footer {
  text-align: center;
  color: white;
  padding: 20px;
  opacity: 0.8;
  font-size: 0.9rem;
  margin-top: 40px;
}

/* Responsive Design */
@media (max-width: 768px) {
  .content-wrapper {
    grid-template-columns: 1fr;
  }

  .app-header h1 {
    font-size: 2rem;
  }

  .chat-container {
    height: 400px;
  }

  .debug-info {
    grid-template-columns: 1fr;
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Technical Concepts Explained

1. WebSockets vs HTTP Requests

  • HTTP: Request-response, not suitable for real-time
  • WebSockets: Persistent connection, bidirectional
  • Socket.io: Library that handles WebSockets with fallbacks

2. Debouncing Implementation

// The core debouncing logic
let typingTimer;

function onTyping() {
  clearTimeout(typingTimer); // Reset timer

  if (!isTyping) {
    sendTypingStarted(); // First keystroke
  }

  typingTimer = setTimeout(() => {
    sendTypingStopped(); // After pause
  }, 1500);
}
Enter fullscreen mode Exit fullscreen mode

3. State Management

  • Frontend: Tracks local typing state
  • Backend: Broadcasts to other users
  • Real-time sync: Immediate updates across clients

4. Optimization Techniques

  • Batching: Group multiple operations
  • Throttling: Limit rate of function calls
  • Memoization: Cache expensive operations

Performance Considerations

  1. Network Efficiency: Debouncing reduces data transfer by 90%+
  2. Battery Life: Fewer network calls save battery
  3. Server Load: Reduced processing overhead
  4. User Experience: Instant feedback without lag

Common Pitfalls and Solutions

1. Flickering Indicators

Problem: Indicator appears/disappears rapidly
Solution: Increase debounce delay slightly

2. Missed "Stopped Typing"

Problem: Sometimes indicator doesn't disappear
Solution: Send "stopped" on message send and input clear

3. Multiple Users Typing

Problem: Indicators overlap or conflict
Solution: Track each user separately, show combined status


Advanced Features (Like WhatsApp)

  1. Read Receipts: Similar pattern with debouncing
  2. Online Status: Heartbeat mechanism
  3. Message Encryption: End-to-end encryption
  4. Media Upload: Progress indicators

Testing the Application

Setup Instructions:

Backend:

mkdir whatsapp-typing-backend
cd whatsapp-typing-backend
npm init -y
npm install express socket.io cors
node server.js
Enter fullscreen mode Exit fullscreen mode

Frontend:

npx create-react-app whatsapp-typing-frontend
cd whatsapp-typing-frontend
npm install socket.io-client
npm start
Enter fullscreen mode Exit fullscreen mode

Testing Scenarios:

  1. Single user typing: Indicator appears instantly
  2. Pause and resume: Indicator reappears
  3. Multiple users: All indicators work independently
  4. Network issues: Graceful degradation

Conclusion

WhatsApp's typing indicator is a masterpiece of real-time engineering that combines:

  1. Debouncing for efficiency
  2. WebSockets for instant communication
  3. Smart state management for accurate indicators
  4. Optimization for performance and battery life

Whether you're building the next WhatsApp clone or adding real-time features to your application, understanding these principles will help you create responsive, efficient, and user-friendly experiences.


Written by Kashaf Abdullah

Software Engineer | MERN Stack | Web Development


Top comments (0)