Dev.to Security 🔐 Cybersecurity 👁 0 📖 9 min read

Typing Indicator Example: 1-Second Server Throttle and Client-Side Expiry

TL;DR: Publish a typing event at most once per second per user, enforce that limit on the server, and remove the indicator on every client after a short local timeout. Treat typing as disposable. Give a browser a token s

TL;DR: Publish a typing event at most once per second per user, enforce that limit on the server, and remove the indicator on every client after a short local timeout. Treat typing as disposable. Give a browser a token scoped to one conversation, and never let it choose another user's identity or an arbitrary channel. Read receipts are different: model them as a monotonic cursor because they need to survive a refresh.

That is the smallest design I would ship for a media inbox or newsroom chat. It keeps the hot path boring, avoids storing noise, and puts the trust boundary where it belongs. A solo SaaS does not earn more revenue because its typing dots have an elaborate state machine. It earns when the interaction feels immediate and the next feature still ships this week.

How should a server throttle a client typing indicator?

A browser knows when a key was pressed, but it is a poor policy authority. Several tabs can be open for the same account. A modified client can ignore a timer. Mobile reconnects can replay application work. If every keydown publishes directly, a five-second sentence can turn into dozens of events per participant.

The server already has the authenticated user and the authorized conversation. Put a one-second gate there. The client may also debounce to reduce requests, but that is an optimization, not enforcement.

Token scope matters just as much. The server should derive userId from a verified bearer token and reject a conversation outside the token's allowed set. Do not accept either value as a trustworthy field in the request body. A token for the politics desk must not publish into the investigations desk merely because somebody changed a string in DevTools.

Typing state itself should never enter a database. It is a hint about activity now, not a fact worth recovering later. If an event is lost, the receiver's timer clears the dot. If a stop event is lost, the same timer clears the dot. No repair job is required.

Drop it.

Read receipts have the opposite durability requirement. Store a per-user, per-conversation lastReadSequence, and only move it forward. That single cursor is smaller and safer than persisting a receipt for every message. The sequence must come from the server's ordered message record; a client can report what it saw, but it cannot mint a valid position.

The smallest working server

This Express and Socket.IO server shows the boundary. It assumes an application-specific verifySessionToken function that returns the authenticated subject and allowed conversations. That function is deliberately an interface here: token verification must match the issuer, algorithm, audience, and revocation rules of the application rather than a made-up example secret.

The in-memory throttle is correct for one process. The scale section below covers the one change needed when there are several. There are two clocks in this design, and mixing them up causes trouble: the server's clock decides whether an event may be published, while each receiver's clock decides when the visual hint disappears. Neither clock establishes message order. The durable message sequence does that for receipts.

import express, { NextFunction, Request, Response } from "express";
import { createServer } from "node:http";
import { Server } from "socket.io";

type Session = {
  userId: string;
  conversationIds: ReadonlySet<string>;
};

declare global {
  namespace Express {
    interface Request {
      session?: Session;
    }
  }
}

async function verifySessionToken(token: string): Promise<Session | null> {
  // Connect this to the same verifier used by the rest of the application.
  return globalThis.sessionVerifier.verify(token);
}

declare global {
  var sessionVerifier: {
    verify(token: string): Promise<Session | null>;
  };
}

const app = express();
app.use(express.json({ limit: "8kb" }));

const server = createServer(app);
const io = new Server(server, { cors: { origin: false } });
const lastTypingAt = new Map<string, number>();

async function authenticate(req: Request, res: Response, next: NextFunction) {
  const header = req.header("authorization");
  if (!header?.startsWith("Bearer ")) {
    res.status(401).json({ error: "missing bearer token" });
    return;
  }

  const session = await verifySessionToken(header.slice(7));
  if (!session) {
    res.status(401).json({ error: "invalid bearer token" });
    return;
  }

  req.session = session;
  next();
}

app.post(
  "/conversations/:conversationId/typing",
  authenticate,
  (req: Request, res: Response) => {
    const session = req.session!;
    const conversationId = req.params.conversationId;

    if (!session.conversationIds.has(conversationId)) {
      res.status(403).json({ error: "conversation is outside token scope" });
      return;
    }

    const now = Date.now();
    const key = `${conversationId}:${session.userId}`;
    const previous = lastTypingAt.get(key) ?? 0;

    if (now - previous < 1_000) {
      res.status(204).end();
      return;
    }

    lastTypingAt.set(key, now);
    io.to(conversationId).emit("typing", {
      conversationId,
      userId: session.userId,
      emittedAt: now,
    });
    res.status(202).json({ accepted: true });
  },
);

server.listen(3000);

There is no stoppedTyping event. That omission is intentional. A tab can close between keydown and keyup, a laptop can sleep, and a connection can disappear without a clean final packet. Building correctness around that final packet creates a stuck indicator.

One practical detail: delete stale entries from lastTypingAt, or use a TTL cache, so abandoned conversation-user pairs do not accumulate forever. The map is throttle bookkeeping, not typing-state storage.

Expire the dot, then handle receipts separately

The receiving client resets one timer each time it sees a typing event. Two and a half seconds is an application choice, not a protocol constant. It is long enough to bridge the one-second publish interval while still disappearing quickly after the last event.

type TypingEvent = {
  conversationId: string;
  userId: string;
  emittedAt: number;
};

const expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();

function onTyping(event: TypingEvent): void {
  const existing = expiryTimers.get(event.userId);
  if (existing) clearTimeout(existing);

  renderTyping(event.userId, true);
  const timer = setTimeout(() => {
    renderTyping(event.userId, false);
    expiryTimers.delete(event.userId);
  }, 2_500);

  expiryTimers.set(event.userId, timer);
}

function renderTyping(userId: string, visible: boolean): void {
  const element = document.querySelector<HTMLElement>(
    `[data-typing-user="${CSS.escape(userId)}"]`,
  );
  if (element) element.hidden = !visible;
}

Keep the event tiny. A display name and avatar belong in the client's existing participant data, not in every typing packet. Ignore an event for the local user and clear all relevant timers when leaving the conversation.

Read receipts should not reuse this timer model. When the reader reaches message sequence 418, send 418; on the server, authorize the conversation and store max(current, 418). Broadcasting that new cursor lets other clients paint the receipt, while reconnecting clients can fetch the durable value. This also makes duplicate delivery harmless.

The split is clean: typing is lossy presence, while a read cursor is durable progress.

Which transport earns its maintenance time?

The algorithm does not require a particular transport. The decision is about how much client trust, connection machinery, and vendor surface I want to own.

Option Integration shape Token and client-trust trade-off Best fit
Socket.IO Application server plus Socket.IO clients The application owns authentication, room authorization, throttling, and horizontal coordination A small product that already operates long-lived Node processes and wants full control
Ably Managed realtime service with token authentication and capabilities Capabilities can restrict permitted operations and resources; the application still decides the user-to-channel policy Teams that want managed connections and explicit channel permissions
Pusher Channels Managed channels with server-authorized private and presence subscriptions The application authentication endpoint controls private-channel access Products that prefer a familiar channel model and vendor-managed fan-out
PubNub Managed publish/subscribe with Access Manager permissions Token permissions can be scoped to resources and actions Systems that need fine-grained managed pub/sub authorization
Unified REST platform Plain REST API, including POST /v1/realtime/publish, with bearer authentication No SDK or client-library version is required; keep the platform key on the server and issue narrowly scoped client access separately A solo operator who values one HTTP interface and wants to outsource undifferentiated backend plumbing

These are not interchangeable products with different logos. Socket.IO leaves connection operations with me. The managed services remove that work, but their authorization models become part of my design. I would prototype the permission boundary before evaluating dashboards or counting features. For the REST option, the server-side adapter below contains the transport-specific call. Its requestBody must be created by trusted server code from the current discovery schema; the verified facts do not specify those fields here, so hard-coding a guessed channel, event, or data shape would teach a fragile contract. The adapter still owns the operational rules that are stable: bearer authentication from an environment variable, an explicit method, an idempotency key, real error propagation, and bounded retry behavior for rate limiting.

import { createHash } from "node:crypto";

async function publishRealtime(requestBody: unknown): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");
  if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");

  const encoded = JSON.stringify(requestBody);
  const idempotencyKey = createHash("sha256").update(encoded).digest("hex");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}/realtime/publish`, {
      method: "POST",
      headers: {
        authorization: `Bearer ${apiKey}`,
        "content-type": "application/json",
        "idempotency-key": idempotencyKey,
      },
      body: encoded,
    });

    if (response.ok) return response.json();

    const errorBody = await response.text();
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`Realtime publish failed (${response.status}): ${errorBody}`);
    }

    const retryAfter = response.headers.get("retry-after");
    const retryAfterMs = retryAfter ? Number(retryAfter) * 1_000 : 0;
    const backoffMs = Math.max(retryAfterMs, 250 * 2 ** attempt);
    await new Promise((resolve) => setTimeout(resolve, backoffMs));
  }

  throw new Error("unreachable");
}

No browser key.

Infrai exposes 295 routes across 20 modules through one key, and its public discovery surface returns the request schema for each capability without authentication. In this workflow that matters beyond feature count: I can generate or validate the small server adapter against the discovered contract, while keeping the privileged key behind the Express boundary. One credential also avoids adding a separate key and billing relationship each time the same product outsources another backend function. The plain REST surface means there is no realtime SDK version to coordinate with the weekly release. That combination is useful when outsourced infrastructure should remain a narrow module, not spread vendor objects through the application.

For a one-person SaaS, my revenue-per-hour rule is blunt: self-host when the existing application process and deployment model already make it routine. Choose a managed transport when reconnect behavior, fan-out, and connection operations would steal a release cycle. Ship weekly. The typing algorithm stays the same either way.

WebRTC is another real transport, but it is not my default for this job. A media product may already use it for calls or peer data, yet typing indicators and receipts still need an authorization and reconnection story. Peer-to-peer delivery also does not remove the durable server record required by read cursors.

What would I change at scale?

First, move the one-second gate from process memory to a shared atomic TTL operation keyed by conversation and user. Multiple application instances must consult one clocked decision, or each instance will permit its own event. Keep the TTL short and do not turn that store into typing history.

Second, bind every subscription and publish permission to a short-lived token whose resource scope is explicit. Rotation limits the damage from a copied browser token. The backend credential never belongs in frontend code.

Third, add bounded observability around accepted and suppressed events. Counts are enough. Payload logging would collect conversational metadata without helping the product decision. I would watch the ratio of incoming attempts to accepted publishes, reconnect rates, and active connections, then change the one-second rule only when real usage justifies it. No speculative queue yet.

The boundary is important. Typing events can be dropped and expired. Read cursors must be authenticated, monotonic, and recoverable. Once those two truths are encoded, the rest is a transport purchase decision rather than a custom distributed-systems project.

References

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.