How I Built a Messenger-Style Typing Indicator in iOS (Swift + Sockets)
A real production case study: socket events, NotificationCenter, and an animated UITableViewCell that doesn’t leak.
A guest opens the chat, asks about a car, and then… nothing. The admin on the other side has no idea if the guest is writing a long question or has already left the app.
That was the situation in a car rental admin app we were building at Codelps. The server was already emitting typing:start and typing:stop events. The iOS app just wasn't listening. So we built a Messenger-style typing indicator, the three pulsing dots you've seen a thousand times, and wired it end to end.
It looks like a small feature. It isn’t. Here’s exactly how we did it, and the mistakes worth avoiding.
The Problem
We had three issues to solve:
- No visual feedback when the guest was typing.
- Socket events existed on the server but weren’t consumed anywhere in the UI.
- The typing row had to live alongside real message rows and hide itself automatically.
That last one is where most implementations break. A typing indicator that gets stuck on screen is worse than no indicator at all.
The Socket Flow
Before writing any UI, we mapped the full flow:
- The peer types, and the server emits
typing:start. ChatSocketManagerposts a.SOCKET_TYPING_STARTnotification.ChatVCsetsisPeerTyping = trueand inserts aTypingIndicatorTableViewCell.- When the peer stops typing or sends a message, the typing row is removed.
- When the local admin types, the app emits
typing:starton text change andtyping:stopon send.
Why NotificationCenter? It keeps the socket layer completely unaware of view controllers. The manager broadcasts, and whichever screen cares can listen.
swift
// ChatSocketManager.swift
socket.on("typing:start") { _, _ in
print("ChatSocket: typing:start received")
DispatchQueue.main.async {
NotificationCenter.default.post(name: .SOCKET_TYPING_START, object: nil)
}
}socket.on("typing:stop") { _, _ in
print("ChatSocket: typing:stop received")
DispatchQueue.main.async {
NotificationCenter.default.post(name: .SOCKET_TYPING_STOP, object: nil)
}
}Notice the DispatchQueue.main.async. Socket callbacks often arrive on a background thread, and touching UITableView from there is one of the fastest ways to get random crashes.
Building the Typing Cell
We built TypingIndicatorTableViewCell fully in code, no XIB. It has a bubble, two small tail views (the "thought bubble" look), and three dots.
The animation is a CABasicAnimation on opacity, with a staggered beginTime for each dot. That stagger is what creates the wave effect.
swift
final class TypingIndicatorTableViewCell: UITableViewCell {static let reuseId = "TypingIndicatorTableViewCell" private let bubble = UIView()
private var dots: [UIView] = [] override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
setupBubble() // bubble + two tail views
setupDots() // three 8pt circles inside the bubble
} required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func startAnimatingDots() {
for (index, dot) in dots.enumerated() {
let pulse = CABasicAnimation(keyPath: "opacity")
pulse.fromValue = 0.3
pulse.toValue = 1.0
pulse.duration = 0.6
pulse.autoreverses = true
pulse.repeatCount = .infinity
pulse.beginTime = CACurrentMediaTime() + Double(index) * 0.2
pulse.fillMode = .backwards
dot.layer.add(pulse, forKey: "typingPulse")
}
} func stopAnimatingDots() {
dots.forEach { $0.layer.removeAnimation(forKey: "typingPulse") }
} override func prepareForReuse() {
super.prepareForReuse()
stopAnimatingDots()
}
}
Two details matter here. First, the bubble uses the same incoming-message color as real guest messages (Colors.BUBBLE_INCOMING_COLOR), so it feels native to the thread. Second, prepareForReuse calls stopAnimatingDots(). Skip that and you'll see "ghost" animations on recycled cells while scrolling.
Integrating It into the Chat Screen
Inside ChatVC, the typing indicator is simply one extra row at the bottom of the table when isPeerTyping is true.
Get Codelps’s stories in your inbox
Join Medium for free to get updates from this writer.
swift
private var isPeerTyping = false
private var peerTypingHideWorkItem: DispatchWorkItem?func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
messages.count + (isPeerTyping ? 1 : 0)
}When a typing:start notification arrives, we show the row and schedule an auto-hide. Every new event cancels the previous timer, so the dots stay visible as long as the guest keeps typing.
swift
@objc private func peerStartedTyping() {
peerTypingHideWorkItem?.cancel()if !isPeerTyping {
isPeerTyping = true
let indexPath = IndexPath(row: messages.count, section: 0)
tableView.insertRows(at: [indexPath], with: .fade)
if isNearBottom { scrollToBottom() }
} let hide = DispatchWorkItem { [weak self] in self?.hideTypingIndicator() }
peerTypingHideWorkItem = hide
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: hide)
}
We only scroll to the bottom if the admin is already near the bottom. Yanking someone away from older messages they’re reading is annoying.
The final rule: when a real message arrives, the typing row goes away first. We handle that inside appendIncomingMessage, so there's never a moment where the dots and the new message sit on screen together.
The Timeout Fallback (Don’t Skip This)
Here’s the lesson that bit us: you can’t trust typing:stop to always arrive. The guest's network drops. They kill the app. They lock their phone mid-sentence.
If your UI only hides the indicator on typing:stop, it will eventually get stuck. The peerTypingHideWorkItem timeout is the safety net. Whatever happens on the network, the dots disappear on their own.
Testing Checklist
Before calling it done, we ran through this on a real device, not just the simulator:
- Guest types → dots appear in the admin thread.
- Guest sends a message → dots disappear and the message shows.
- Guest stops typing → dots disappear after the timeout.
- Scrolling and cell reuse → no ghost animations.
Common Mistakes to Avoid
These showed up either in this feature or in related chat work on the same project:
- Updating UITableView from a background thread. Always hop to the main queue.
- Forgetting
prepareForReusecleanup on animated cells. - Deduplicating chat messages by text instead of the server ID. Two guests can type “ok”.
- Assuming one payload shape forever. Backends change. Keep parsing in the manager layer.
- Changing socket, UI, and push logic in one commit. Fix one layer at a time.
Key Takeaways
Typing indicators need three things to feel right: a clean event pipeline, a cell that cleans up after itself, and a timeout that never lets the UI lie. And always hide the typing row the moment a real message lands.
With a write-up like this, a junior developer can ship the same feature in 4–6 hours instead of 1–2 days. That’s why we document every non-trivial feature at Codelps.
I’m Hafiz Anser, founder of Codelps, a Lahore-based studio that has shipped 100+ mobile apps for US founders. If you’re building a product with real-time chat and need an iOS team that sweats these details, reach out at info@codelps.com or visit codelps.com.










