Skip to main content
The Typing Indicators API provides RPC methods for managing real-time typing status in channels. Typing indicators show when users are actively composing messages, improving the chat experience by providing immediate feedback.

Overview

Typing indicators allow users to see when others are typing in a channel. Each typing indicator:
  • Associates a channel member with their typing activity
  • Tracks the last time they typed (timestamp)
  • Automatically expires after inactivity
  • Uses upsert operations for efficiency
Typing indicators enhance user experience by:
  • Showing real-time activity
  • Preventing message collisions
  • Creating a more responsive feel
  • Indicating channel engagement

Authentication

All typing indicator operations require authentication via AuthMiddleware.

Methods

typingIndicator.create

Creates or updates a typing indicator (upsert operation). If a typing indicator already exists for the channelId/memberId combination, it will be updated. Otherwise, a new one is created.
This method uses an upsert pattern, so you can call it repeatedly as the user types without worrying about duplicates.
channelId
string (UUID)
required
The ID of the channel where the user is typing
memberId
string (UUID)
required
The ID of the channel member who is typing
lastTyped
number
Unix timestamp (milliseconds) of the last typing activity. Defaults to current time if not provided.

Response

data
TypingIndicator
The created or updated typing indicator object
transactionId
string
Transaction ID for optimistic UI updates

Errors

  • UnauthorizedError - User lacks permission to create typing indicators
  • InternalServerError - An unexpected error occurred

Example

import { RpcClient } from "@hazel/domain/rpc"
import { Effect } from "effect"

const startTyping = Effect.gen(function* () {
  const client = yield* RpcClient
  
  const result = yield* client.TypingIndicatorCreate({
    channelId: "550e8400-e29b-41d4-a716-446655440000",
    memberId: "660e8400-e29b-41d4-a716-446655440001",
    lastTyped: Date.now(),
  })
  
  console.log("Typing indicator created:", result.data.id)
  return result
})

typingIndicator.update

Updates an existing typing indicator’s timestamp. Only the typing indicator owner or users with appropriate permissions can update.
In most cases, use typingIndicator.create instead, as it handles both creation and updates (upsert).
id
string (UUID)
required
The ID of the typing indicator to update
lastTyped
number
Updated Unix timestamp of typing activity

Response

data
TypingIndicator
The updated typing indicator object
transactionId
string
Transaction ID for optimistic UI updates

Errors

  • TypingIndicatorNotFoundError - The specified typing indicator does not exist
  • UnauthorizedError - User lacks permission to update this typing indicator
  • InternalServerError - An unexpected error occurred

Example

import { RpcClient } from "@hazel/domain/rpc"
import { Effect } from "effect"

const updateTyping = Effect.gen(function* () {
  const client = yield* RpcClient
  
  const result = yield* client.TypingIndicatorUpdate({
    id: "770e8400-e29b-41d4-a716-446655440002",
    lastTyped: Date.now(),
  })
  
  return result
})

typingIndicator.delete

Deletes a typing indicator (hard delete). Called when a user stops typing in a channel.
id
string (UUID)
required
The ID of the typing indicator to delete

Response

data
TypingIndicator
The deleted typing indicator object
transactionId
string
Transaction ID for optimistic UI updates

Errors

  • TypingIndicatorNotFoundError - The specified typing indicator does not exist
  • UnauthorizedError - User lacks permission to delete this typing indicator
  • InternalServerError - An unexpected error occurred

Example

import { RpcClient } from "@hazel/domain/rpc"
import { Effect } from "effect"

const stopTyping = Effect.gen(function* () {
  const client = yield* RpcClient
  
  const result = yield* client.TypingIndicatorDelete({
    id: typingIndicatorId,
  })
  
  console.log("Typing indicator removed")
  return result
})

Best Practices

Debounce Typing Updates

Debounce typing indicator updates to reduce server load:
const TYPING_DEBOUNCE_MS = 1000
const TYPING_THROTTLE_MS = 3000

let lastTypingUpdate = 0
let typingTimeout: NodeJS.Timeout | null = null

const handleTyping = () => {
  const now = Date.now()
  
  // Throttle: Don't update more often than every 3 seconds
  if (now - lastTypingUpdate < TYPING_THROTTLE_MS) {
    return
  }
  
  // Debounce: Wait for user to stop typing briefly
  if (typingTimeout) {
    clearTimeout(typingTimeout)
  }
  
  typingTimeout = setTimeout(() => {
    Effect.gen(function* () {
      const client = yield* RpcClient
      
      yield* client.TypingIndicatorCreate({
        channelId: currentChannelId,
        memberId: currentMemberId,
        lastTyped: now,
      })
      
      lastTypingUpdate = now
    }).pipe(Effect.runPromise)
  }, TYPING_DEBOUNCE_MS)
}

Auto-Expire Indicators

Automatically remove stale typing indicators on the client:
const TYPING_TIMEOUT_MS = 5000 // 5 seconds

interface TypingState {
  userId: string
  lastTyped: number
}

const cleanupStaleIndicators = () => {
  const now = Date.now()
  const activeIndicators = typingIndicators.filter(
    (indicator) => now - indicator.lastTyped < TYPING_TIMEOUT_MS
  )
  
  setTypingIndicators(activeIndicators)
}

// Run cleanup periodically
setInterval(cleanupStaleIndicators, 1000)

Display Typing Users

Show typing indicators in a user-friendly way:
const TypingIndicator = ({ typingUsers }) => {
  if (typingUsers.length === 0) return null
  
  const getUserNames = () => {
    if (typingUsers.length === 1) {
      return `${typingUsers[0].username} is typing`
    } else if (typingUsers.length === 2) {
      return `${typingUsers[0].username} and ${typingUsers[1].username} are typing`
    } else if (typingUsers.length === 3) {
      return `${typingUsers[0].username}, ${typingUsers[1].username}, and ${typingUsers[2].username} are typing`
    } else {
      return `${typingUsers.length} people are typing`
    }
  }
  
  return (
    <div className="typing-indicator">
      <span className="typing-text">{getUserNames()}</span>
      <span className="typing-dots">
        <span>.</span>
        <span>.</span>
        <span>.</span>
      </span>
    </div>
  )
}

Stop Typing on Message Send

Always clear the typing indicator when sending a message:
const sendMessageWithTypingCleanup = Effect.gen(function* () {
  const client = yield* RpcClient
  
  try {
    // Send message
    yield* client.MessageCreate(messageData)
    
    // Clear typing indicator
    if (currentTypingIndicatorId) {
      yield* client.TypingIndicatorDelete({
        id: currentTypingIndicatorId,
      })
      currentTypingIndicatorId = null
    }
    
    // Clear input
    setMessageContent("")
  } catch (error) {
    console.error("Failed to send message:", error)
  }
})

Handle Focus and Blur

Remove typing indicator when user leaves the input:
const MessageInput = () => {
  const handleFocus = () => {
    // User started typing
    handleTyping()
  }
  
  const handleBlur = () => {
    Effect.gen(function* () {
      const client = yield* RpcClient
      
      // User left input, remove typing indicator
      if (currentTypingIndicatorId) {
        yield* client.TypingIndicatorDelete({
          id: currentTypingIndicatorId,
        })
        currentTypingIndicatorId = null
      }
    }).pipe(Effect.runPromise)
  }
  
  return (
    <input
      onFocus={handleFocus}
      onBlur={handleBlur}
      onChange={handleTyping}
    />
  )
}

Real-time Subscriptions

Subscribe to typing indicator changes via WebSocket:
const subscribeToTypingIndicators = (channelId: string) => {
  return Effect.gen(function* () {
    const ws = yield* WebSocketClient
    
    yield* ws.subscribe(
      `channel:${channelId}:typing`,
      (indicator: TypingIndicator) => {
        // Update typing indicators list
        setTypingIndicators((prev) => {
          const existing = prev.find((i) => i.memberId === indicator.memberId)
          
          if (existing) {
            // Update existing
            return prev.map((i) =>
              i.memberId === indicator.memberId ? indicator : i
            )
          } else {
            // Add new
            return [...prev, indicator]
          }
        })
      }
    )
  })
}

Batch Updates

Batch multiple typing indicator updates together:
const pendingUpdates = new Map<string, TypingIndicator>()
let updateTimer: NodeJS.Timeout | null = null

const queueTypingUpdate = (indicator: TypingIndicator) => {
  pendingUpdates.set(indicator.memberId, indicator)
  
  if (updateTimer) {
    clearTimeout(updateTimer)
  }
  
  updateTimer = setTimeout(() => {
    flushTypingUpdates()
  }, 500)
}

const flushTypingUpdates = () => {
  Effect.gen(function* () {
    const client = yield* RpcClient
    
    // Send all pending updates
    yield* Effect.forEach(Array.from(pendingUpdates.values()), (indicator) =>
      client.TypingIndicatorCreate(indicator)
    )
    
    pendingUpdates.clear()
  }).pipe(Effect.runPromise)
}

Source Code Reference

  • RPC Contracts: packages/domain/src/rpc/typing-indicators.ts
  • TypingIndicator Model: packages/domain/src/models/typing-indicator-model.ts
  • Backend Handlers: apps/backend/src/rpc/handlers/typing-indicators.ts (if exists)

Build docs developers (and LLMs) love