Build a Real-Time Chat App: WebSockets, Message Bubbles, and Typing Indicators


Picture this: the entire Pixar production team is scattered across studios, and they need a secure, real-time messaging app to coordinate on the next film — Woody filing storyboard notes to Buzz, Remy and Linguini debating the soup scene in real time, Dory asking what she was just talking about. Your job is to build it.

In this tutorial, you’ll build PixarChat — a fully functional real-time messaging app powered by WebSockets. You’ll implement a persistent connection manager using URLSessionWebSocketTask, craft custom SwiftUI message bubble views, add animated typing indicators, wire up read receipts, and group messages by timestamp. We won’t cover push notifications or end-to-end encryption — those deserve their own tutorials.

Prerequisites

Note: This tutorial uses a local WebSocket echo/broadcast server for development. Setup instructions are in Getting Started. The same ChatService code works against any production WebSocket backend without modification.

Contents

Getting Started

Start by creating a fresh Xcode project.

  1. Open Xcode and select File → New → Project.
  2. Choose the App template under iOS.
  3. Set the Product Name to PixarChat.
  4. Set the Interface to SwiftUI and the Language to Swift.
  5. Set the Minimum Deployments to iOS 18.0 in the project editor.

Next, you need a local WebSocket server to develop against. Create a file called server.js outside your Xcode project folder:

// server.js — simple WebSocket broadcast server for local development
// Run with: node server.js
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
const clients = new Set();

wss.on('connection', (ws) => {
  clients.add(ws);
  ws.on('message', (data) => {
    // Broadcast every incoming frame to all connected clients
    for (const client of clients) {
      if (client.readyState === 1) client.send(data.toString());
    }
  });
  ws.on('close', () => clients.delete(ws));
});

console.log('WebSocket server running on ws://localhost:8080');

Run it with node server.js (requires Node.js). Every message the app sends is broadcast to all connected clients — perfect for simulating a shared Pixar production chat room. To see two-way messaging, open a second simulator or a WebSocket GUI client alongside the running app.

Open your project’s Info.plist (or add the key via the Info tab in project settings) and allow local WebSocket connections:

<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsLocalNetworking</key>
  <true/>
</dict>

This permits ws://localhost connections. In production you’ll use a wss:// (TLS-secured) endpoint and this exception is not needed.

Finally, create the following folder structure inside the PixarChat group in Xcode — use File → New → Group to add folders, then File → New → File → Swift File for each .swift file:

PixarChat/
  Models/
    ChatMessage.swift
    ChatUser.swift
    MessageEnvelope.swift
  Services/
    ChatService.swift
  Views/
    ChatView.swift
    MessageBubbleView.swift
    TypingIndicatorView.swift
    TimestampDividerView.swift
  PixarChatApp.swift

Checkpoint: Build the project (⌘B). You should get a clean build with no errors. The empty folder structure is ready for the code you’re about to add.

Step 1: Defining the Data Model

A solid data model is the foundation of any messaging app. Define your types before touching networking or UI — it keeps the architecture honest.

Open Models/ChatUser.swift and add:

import Foundation

// A member of the Pixar production team.
struct ChatUser: Identifiable, Hashable, Sendable {
    let id: UUID
    let displayName: String
    let avatarInitials: String

    // The user running the app on this device.
    static let currentUser = ChatUser(
        id: UUID(),
        displayName: "Remy",
        avatarInitials: "R"
    )

    // A fixed remote participant for demo purposes.
    static let remoteUser = ChatUser(
        id: UUID(uuidString: "DEADBEEF-0000-0000-0000-000000000001")!,
        displayName: "Linguini",
        avatarInitials: "L"
    )
}

ChatUser conforms to Sendable because it crosses actor isolation boundaries inside ChatService. It’s a value type, so the compiler confirms this automatically.

Open Models/ChatMessage.swift:

import Foundation

// The canonical in-memory representation of a single chat message.
struct ChatMessage: Identifiable, Sendable {
    enum Status: Sendable {
        case sending    // Optimistically inserted, send in flight
        case sent       // WebSocket send() returned successfully
        case delivered  // Remote client received it
        case read       // Remote client opened the conversation
    }

    let id: UUID
    let sender: ChatUser
    let body: String
    let sentAt: Date
    var status: Status

    var isFromCurrentUser: Bool {
        sender.id == ChatUser.currentUser.id
    }
}

The Status enum drives the read-receipt indicator you’ll build in Step 6. isFromCurrentUser is a computed property — it derives truth from the sender’s identity, which prevents the two from ever getting out of sync.

Open Models/MessageEnvelope.swift and add the wire format:

import Foundation

// JSON payload sent and received over the WebSocket connection.
struct MessageEnvelope: Codable, Sendable {
    enum EventType: String, Codable, Sendable {
        case message
        case typing
        case readReceipt
    }

    let event: EventType
    let messageId: String
    let senderId: String
    let senderName: String
    let body: String
    let sentAt: Double   // Unix timestamp — avoids locale-sensitive date parsing

    static let encoder: JSONEncoder = {
        let enc = JSONEncoder()
        enc.keyEncodingStrategy = .convertToSnakeCase
        return enc
    }()

    static let decoder: JSONDecoder = {
        let dec = JSONDecoder()
        dec.keyDecodingStrategy = .convertFromSnakeCase
        return dec
    }()
}

The static encoder and decoder are shared instances. Creating a fresh JSONEncoder for every message is wasteful — the setup cost is non-trivial.

Checkpoint: Build the project. All three model files should compile without warnings. If the compiler reports “Type does not conform to ‘Sendable’”, verify every stored property is itself Sendable (structs with only value-type properties satisfy this automatically).

Step 2: Building the WebSocket Connection Manager

URLSessionWebSocketTask is Foundation’s WebSocket API, available since iOS 13. It handles the HTTP→WebSocket upgrade handshake, frame fragmentation, and ping/pong automatically. Your job is to wrap it in a @MainActor-isolated ObservableObject so all UI state mutations stay on the main thread.

Create Services/ChatService.swift:

import Foundation

@MainActor
final class ChatService: ObservableObject {

    // MARK: - Published State

    @Published private(set) var messages: [ChatMessage] = []
    @Published private(set) var connectionState: ConnectionState = .disconnected
    @Published private(set) var remoteUserIsTyping: Bool = false

    enum ConnectionState: Equatable {
        case disconnected
        case connecting
        case connected
        case failed(String)  // String rather than Error — Error isn't Equatable

        static func == (lhs: ConnectionState, rhs: ConnectionState) -> Bool {
            switch (lhs, rhs) {
            case (.disconnected, .disconnected),
                 (.connecting, .connecting),
                 (.connected, .connected):
                return true
            case (.failed(let a), .failed(let b)):
                return a == b
            default:
                return false
            }
        }
    }

    // MARK: - Private State

    private var webSocketTask: URLSessionWebSocketTask?
    private let session: URLSession = URLSession(configuration: .default)
    private var receiveTask: Task<Void, Never>?
    private var typingTask: Task<Void, Never>?
    private var pingTask: Task<Void, Never>?
    private let serverURL: URL

    init(serverURL: URL = URL(string: "ws://localhost:8080")!) {
        self.serverURL = serverURL
    }
}

@MainActor isolation means every method and property access on ChatService executes on the main thread. This eliminates the need for manual DispatchQueue.main.async calls around @Published mutations — the Swift compiler enforces the isolation at compile time.

Now add the connection and send methods. Append this extension to the same file:

extension ChatService {

    // MARK: - Connection Lifecycle

    func connect() {
        guard connectionState == .disconnected else { return }
        connectionState = .connecting

        var request = URLRequest(url: serverURL)
        request.timeoutInterval = 10
        webSocketTask = session.webSocketTask(with: request)
        webSocketTask?.resume()
        connectionState = .connected

        // Kick off the continuous receive loop
        receiveTask = Task { [weak self] in
            await self?.receiveLoop()
        }

        // Keepalive ping every 20 s prevents NAT and load-balancer timeouts
        pingTask = Task { [weak self] in
            while let self, !Task.isCancelled {
                try? await Task.sleep(for: .seconds(20))
                try? await self.webSocketTask?.sendPing()
            }
        }
    }

    func disconnect() {
        receiveTask?.cancel()
        receiveTask = nil
        pingTask?.cancel()
        pingTask = nil
        typingTask?.cancel()
        typingTask = nil
        webSocketTask?.cancel(with: .goingAway, reason: nil)
        webSocketTask = nil
        connectionState = .disconnected
    }

    // MARK: - Sending

    func send(_ text: String) async {
        guard connectionState == .connected else { return }

        let envelope = MessageEnvelope(
            event: .message,
            messageId: UUID().uuidString,
            senderId: ChatUser.currentUser.id.uuidString,
            senderName: ChatUser.currentUser.displayName,
            body: text,
            sentAt: Date().timeIntervalSince1970
        )

        // Optimistically append the outgoing message before the send completes
        let outgoing = ChatMessage(
            id: UUID(uuidString: envelope.messageId)!,
            sender: ChatUser.currentUser,
            body: text,
            sentAt: Date(timeIntervalSince1970: envelope.sentAt),
            status: .sending
        )
        messages.append(outgoing)

        do {
            let data = try MessageEnvelope.encoder.encode(envelope)
            let json = String(data: data, encoding: .utf8)!
            try await webSocketTask?.send(.string(json))
            updateStatus(for: outgoing.id, to: .sent)
        } catch {
            // Roll back the optimistic insert on failure
            messages.removeAll { $0.id == outgoing.id }
        }
    }

    func sendTypingEvent() {
        // Cancel the previous typing task so we debounce keystrokes
        typingTask?.cancel()
        typingTask = Task { [weak self] in
            guard let self, connectionState == .connected else { return }
            let envelope = MessageEnvelope(
                event: .typing,
                messageId: UUID().uuidString,
                senderId: ChatUser.currentUser.id.uuidString,
                senderName: ChatUser.currentUser.displayName,
                body: "",
                sentAt: Date().timeIntervalSince1970
            )
            guard let data = try? MessageEnvelope.encoder.encode(envelope),
                  let json = String(data: data, encoding: .utf8) else { return }
            try? await webSocketTask?.send(.string(json))
        }
    }

    func markAsRead(messageId: UUID) async {
        guard connectionState == .connected else { return }
        let envelope = MessageEnvelope(
            event: .readReceipt,
            messageId: messageId.uuidString,
            senderId: ChatUser.currentUser.id.uuidString,
            senderName: ChatUser.currentUser.displayName,
            body: "",
            sentAt: Date().timeIntervalSince1970
        )
        guard let data = try? MessageEnvelope.encoder.encode(envelope),
              let json = String(data: data, encoding: .utf8) else { return }
        // Read receipts are best-effort — ignore send errors
        try? await webSocketTask?.send(.string(json))
        updateStatus(for: messageId, to: .read)
    }

    // MARK: - Private

    private func receiveLoop() async {
        while !Task.isCancelled {
            do {
                guard let task = webSocketTask else { break }
                let frame = try await task.receive()
                handleFrame(frame)
            } catch {
                if !Task.isCancelled {
                    connectionState = .failed(error.localizedDescription)
                }
                break
            }
        }
    }

    private func handleFrame(_ frame: URLSessionWebSocketTask.Message) {
        let jsonString: String
        switch frame {
        case .string(let s): jsonString = s
        case .data(let d):
            jsonString = String(data: d, encoding: .utf8) ?? ""
        @unknown default: return
        }

        guard let data = jsonString.data(using: .utf8),
              let envelope = try? MessageEnvelope.decoder.decode(
                  MessageEnvelope.self, from: data) else { return }

        switch envelope.event {
        case .message:
            // The broadcast server echoes our own messages back — skip them
            guard envelope.senderId != ChatUser.currentUser.id.uuidString else {
                return
            }
            let incoming = ChatMessage(
                id: UUID(uuidString: envelope.messageId) ?? UUID(),
                sender: ChatUser.remoteUser,
                body: envelope.body,
                sentAt: Date(timeIntervalSince1970: envelope.sentAt),
                status: .delivered
            )
            messages.append(incoming)
            remoteUserIsTyping = false

        case .typing:
            guard envelope.senderId != ChatUser.currentUser.id.uuidString else {
                return
            }
            remoteUserIsTyping = true
            // Auto-clear the typing indicator after 3 s of silence
            Task {
                try? await Task.sleep(for: .seconds(3))
                remoteUserIsTyping = false
            }

        case .readReceipt:
            if let msgId = UUID(uuidString: envelope.messageId) {
                updateStatus(for: msgId, to: .read)
            }
        }
    }

    private func updateStatus(for id: UUID, to status: ChatMessage.Status) {
        guard let index = messages.firstIndex(where: { $0.id == id }) else { return }
        messages[index].status = status
    }
}

Three design decisions worth understanding:

  • Optimistic append: The outgoing message lands in messages immediately with .sending status. This feels instant to the user regardless of network latency. If the actual send throws, the message is removed — matching how iMessage handles failed sends.
  • receiveLoop(): A while !Task.isCancelled loop suspends at await task.receive() without blocking any thread. When a WebSocket frame arrives, Swift resumes the loop, processes the frame, then suspends again. Calling cancel() on receiveTask causes the next receive() to throw CancellationError, breaking the loop cleanly.
  • Typing debounce: Cancelling the previous typingTask before creating a new one means only one typing event fires per burst of keystrokes, not one per character.

Checkpoint: Build the project. ChatService compiles without warnings. The connection isn’t wired to any UI yet — that comes in Step 7.

Step 3: Creating the Message Bubble View

The bubble UI is the most visible part of the app. Build a reusable MessageBubbleView that mirrors the iMessage aesthetic: outgoing bubbles on the right in the accent color, incoming on the left in a neutral fill, with per-corner radii creating a subtle tail.

Create Views/MessageBubbleView.swift:

import SwiftUI

struct MessageBubbleView: View {
    let message: ChatMessage

    private var isOutgoing: Bool { message.isFromCurrentUser }

    var body: some View {
        HStack(alignment: .bottom, spacing: 6) {
            if isOutgoing { Spacer(minLength: 60) }

            if !isOutgoing {
                avatarView
            }

            VStack(alignment: isOutgoing ? .trailing : .leading, spacing: 3) {
                bubbleBody
                metaRow
            }

            if !isOutgoing { Spacer(minLength: 60) }
        }
        .padding(.horizontal, 12)
    }

    // MARK: - Sub-views

    private var avatarView: some View {
        Circle()
            .fill(Color(.systemGray4))
            .frame(width: 32, height: 32)
            .overlay(
                Text(message.sender.avatarInitials)
                    .font(.caption2.bold())
                    .foregroundStyle(.secondary)
            )
    }

    private var bubbleBody: some View {
        Text(message.body)
            .padding(.horizontal, 14)
            .padding(.vertical, 10)
            .background(
                isOutgoing ? Color.accentColor : Color(.systemGray5),
                in: BubbleShape(isOutgoing: isOutgoing)
            )
            .foregroundStyle(isOutgoing ? .white : .primary)
    }

    private var metaRow: some View {
        HStack(spacing: 4) {
            Text(message.sentAt, style: .time)
                .font(.caption2)
                .foregroundStyle(.secondary)
            if isOutgoing {
                ReadReceiptView(status: message.status)
            }
        }
    }
}

The BubbleShape gives each bubble a rounded rectangle with one corner slightly less rounded — the classic chat tail. Add it to the same file:

// A rounded rectangle with one tighter corner to suggest a chat tail.
struct BubbleShape: Shape {
    let isOutgoing: Bool

    func path(in rect: CGRect) -> Path {
        let radius: CGFloat = 18
        let tailRadius: CGFloat = 6

        // The tail corner is top-right for outgoing, top-left for incoming
        return Path(roundedRect: rect, cornerRadii: RectangleCornerRadii(
            topLeading: isOutgoing ? radius : tailRadius,
            bottomLeading: radius,
            bottomTrailing: radius,
            topTrailing: isOutgoing ? tailRadius : radius
        ))
    }
}

Apple Docs: RectangleCornerRadii — SwiftUI, iOS 16+

The per-corner radius API replaces the cumbersome manual Bezier path workaround that was common in UIKit chat UIs.

Now add ReadReceiptView to the same file:

struct ReadReceiptView: View {
    let status: ChatMessage.Status

    var body: some View {
        Group {
            switch status {
            case .sending:
                Image(systemName: "clock")
            case .sent:
                Image(systemName: "checkmark")
            case .delivered:
                Image(systemName: "checkmark.circle")
            case .read:
                Image(systemName: "checkmark.circle.fill")
                    .foregroundStyle(.blue)
            }
        }
        .font(.caption2)
        .foregroundStyle(.secondary)
        .transition(.opacity)
        .animation(.easeInOut(duration: 0.25), value: status)
    }
}

Each status maps to a recognizable SF Symbol. The .animation(_:value:) modifier cross-fades between icons as the status upgrades from .sending.sent.delivered.read.

Checkpoint: Add a SwiftUI preview to MessageBubbleView.swift to verify the layout:

#Preview {
    VStack(spacing: 8) {
        MessageBubbleView(message: ChatMessage(
            id: UUID(),
            sender: .currentUser,
            body: "We need to talk about the lamp scene.",
            sentAt: Date(),
            status: .read
        ))
        MessageBubbleView(message: ChatMessage(
            id: UUID(),
            sender: .remoteUser,
            body: "Too much lamp? I think it needs more lamp.",
            sentAt: Date(),
            status: .delivered
        ))
    }
    .padding(.vertical)
}

You should see the outgoing bubble on the right in the accent color with a blue “read” checkmark, and the incoming bubble on the left in gray with Linguini’s avatar.

Step 4: Adding Timestamp Grouping

Real chat apps don’t show a timestamp beside every message — that creates visual noise. Instead they insert a single centered divider between bursts of messages more than a few minutes apart.

Create Views/TimestampDividerView.swift:

import SwiftUI

struct TimestampDividerView: View {
    let date: Date

    var body: some View {
        HStack {
            line
            Text(formatted(date))
                .font(.caption2)
                .foregroundStyle(.secondary)
                .padding(.horizontal, 8)
                .fixedSize()         // Prevents the label truncating in narrow layouts
            line
        }
        .padding(.vertical, 4)
        .padding(.horizontal, 16)
    }

    private var line: some View {
        Rectangle()
            .fill(Color(.separator))
            .frame(height: 0.5)
    }

    // Returns a human-readable string appropriate to how old the date is.
    private func formatted(_ date: Date) -> String {
        let calendar = Calendar.current
        if calendar.isDateInToday(date) {
            return date.formatted(date: .omitted, time: .shortened)
        } else if calendar.isDateInYesterday(date) {
            return "Yesterday " + date.formatted(date: .omitted, time: .shortened)
        } else {
            return date.formatted(date: .abbreviated, time: .shortened)
        }
    }
}

Now add a free function to Models/ChatMessage.swift (outside the struct) that determines when to insert a divider:

/// Returns `true` if a timestamp divider should appear before `message`.
/// Dividers separate bursts that are more than 5 minutes apart.
func shouldShowTimestamp(before message: ChatMessage, after prior: ChatMessage?) -> Bool {
    guard let prior else { return true }
    return message.sentAt.timeIntervalSince(prior.sentAt) > 5 * 60
}

Five minutes is the threshold iMessage uses. The function takes an optional prior message rather than an index because the call site in the view iterates with enumerated(), making it easy to pass the previous element without reaching back into the array.

Step 5: Implementing Typing Indicators

The three-dot typing indicator signals that someone is composing a message. It’s a small touch that dramatically increases the sense of presence in any real-time app.

Create Views/TypingIndicatorView.swift:

import SwiftUI

struct TypingIndicatorView: View {
    @State private var animatingDotIndex: Int = 0

    private let dotSize: CGFloat = 8
    private let dotSpacing: CGFloat = 5

    var body: some View {
        HStack(alignment: .bottom, spacing: 6) {
            // Avatar matches the remote user's bubble layout
            Circle()
                .fill(Color(.systemGray4))
                .frame(width: 32, height: 32)
                .overlay(
                    Text(ChatUser.remoteUser.avatarInitials)
                        .font(.caption2.bold())
                        .foregroundStyle(.secondary)
                )

            HStack(spacing: dotSpacing) {
                ForEach(0..<3, id: \.self) { index in
                    Circle()
                        .fill(Color(.systemGray3))
                        .frame(width: dotSize, height: dotSize)
                        .scaleEffect(animatingDotIndex == index ? 1.4 : 1.0)
                        .animation(
                            .easeInOut(duration: 0.4)
                                .repeatForever(autoreverses: true),
                            value: animatingDotIndex
                        )
                }
            }
            .padding(.horizontal, 16)
            .padding(.vertical, 12)
            .background(
                Color(.systemGray5),
                in: RoundedRectangle(cornerRadius: 18, style: .continuous)
            )

            Spacer(minLength: 60)
        }
        .padding(.horizontal, 12)
        .onAppear { startAnimation() }
        .transition(.asymmetric(
            insertion: .move(edge: .bottom).combined(with: .opacity),
            removal: .opacity
        ))
    }

    private func startAnimation() {
        Task {
            while !Task.isCancelled {
                for i in 0..<3 {
                    withAnimation { animatingDotIndex = i }
                    try? await Task.sleep(for: .milliseconds(300))
                }
            }
        }
    }
}

The asymmetric transition gives the indicator a slide-up-and-fade-in entrance and an instant fade-out on removal. This matches the behavior of Messages on iOS — the bubble doesn’t awkwardly collapse when the first incoming message arrives.

Tip: If the scale animation looks jerky in the simulator, run on a physical device. The repeatForever(autoreverses: true) modifier is GPU-composited and runs at full frame rate on hardware.

Step 6: Adding Read Receipts

The ReadReceiptView from Step 3 already renders the correct SF Symbol for each status. The remaining work is sending a readReceipt event over the WebSocket so the remote participant’s outgoing messages upgrade to .read.

The markAsRead(messageId:) method on ChatService (written in Step 2) handles this. You’ll call it from the chat view in Step 7 using onAppear when the message list becomes visible.

A production implementation would be more selective — only send a receipt for messages that were genuinely new when the user opened the conversation, and only while the app is in the foreground. For this tutorial, calling markAsRead on the last incoming message when ChatView appears is a clean approximation.

Note: The broadcast server echoes the readReceipt event back to all clients, including the sender. handleFrame in ChatService matches the messageId in the receipt against the outgoing messages and upgrades their status. In a production backend you’d route receipts only to the original sender.

Step 7: Wiring Up the Chat View

With all the components built, assemble them into the main ChatView.

Create Views/ChatView.swift:

import SwiftUI

struct ChatView: View {
    @StateObject private var service = ChatService()
    @State private var inputText: String = ""
    @FocusState private var isInputFocused: Bool

    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                connectionBanner
                messageList
                inputBar
            }
            .navigationTitle("Pixar Production Chat")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar { statusDot }
        }
        .task {
            // .task ties the connection to the view's lifetime automatically
            service.connect()
        }
        .onDisappear {
            service.disconnect()
        }
        .onChange(of: service.connectionState) {
            if case .failed = service.connectionState {
                service.reconnectIfNeeded()
            }
        }
    }

    // MARK: - Connection Banner

    @ViewBuilder
    private var connectionBanner: some View {
        switch service.connectionState {
        case .connecting:
            banner("Connecting to the studio...", color: .orange)
        case .failed(let msg):
            banner("Connection lost: \(msg)", color: .red)
        default:
            EmptyView()
        }
    }

    private func banner(_ text: String, color: Color) -> some View {
        Text(text)
            .font(.caption)
            .foregroundStyle(.white)
            .padding(.vertical, 6)
            .frame(maxWidth: .infinity)
            .background(color)
            .transition(.move(edge: .top).combined(with: .opacity))
    }

    // MARK: - Message List

    private var messageList: some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack(spacing: 2) {
                    ForEach(
                        Array(service.messages.enumerated()),
                        id: \.element.id
                    ) { index, message in
                        let prior = index > 0 ? service.messages[index - 1] : nil

                        if shouldShowTimestamp(before: message, after: prior) {
                            TimestampDividerView(date: message.sentAt)
                                .padding(.top, 8)
                        }

                        MessageBubbleView(message: message)
                            .id(message.id)
                    }

                    if service.remoteUserIsTyping {
                        TypingIndicatorView()
                            .id("typing")
                            .padding(.top, 4)
                    }
                }
                .padding(.vertical, 8)
                .animation(.default, value: service.messages.count)
                .animation(.default, value: service.remoteUserIsTyping)
            }
            .onChange(of: service.messages.count) {
                scrollToBottom(proxy: proxy)
            }
            .onChange(of: service.remoteUserIsTyping) {
                if service.remoteUserIsTyping {
                    withAnimation {
                        proxy.scrollTo("typing", anchor: .bottom)
                    }
                }
            }
            .onAppear {
                // Mark the last incoming message as read when the view appears
                Task {
                    if let last = service.messages.last(
                        where: { !$0.isFromCurrentUser }) {
                        await service.markAsRead(messageId: last.id)
                    }
                }
            }
        }
    }

    // MARK: - Input Bar

    private var inputBar: some View {
        HStack(spacing: 10) {
            // axis: .vertical enables the multi-line grow behavior (iOS 16+)
            TextField("Message the team...", text: $inputText, axis: .vertical)
                .textFieldStyle(.plain)
                .lineLimit(1...5)
                .padding(.horizontal, 14)
                .padding(.vertical, 10)
                .background(
                    Color(.systemGray6),
                    in: RoundedRectangle(cornerRadius: 20, style: .continuous)
                )
                .focused($isInputFocused)
                .onChange(of: inputText) {
                    if !inputText.isEmpty {
                        service.sendTypingEvent()
                    }
                }

            sendButton
        }
        .padding(.horizontal, 12)
        .padding(.vertical, 8)
        .background(.bar)
    }

    private var sendButton: some View {
        let isEmpty = inputText.trimmingCharacters(in: .whitespaces).isEmpty
        return Button {
            sendMessage()
        } label: {
            Image(systemName: "arrow.up.circle.fill")
                .font(.title2)
                .foregroundStyle(isEmpty ? Color(.systemGray3) : Color.accentColor)
        }
        .disabled(isEmpty)
    }

    // MARK: - Toolbar

    @ToolbarContentBuilder
    private var statusDot: some ToolbarContent {
        ToolbarItem(placement: .topBarTrailing) {
            Circle()
                .fill(
                    service.connectionState == .connected
                        ? Color.green : Color.red
                )
                .frame(width: 10, height: 10)
        }
    }

    // MARK: - Actions

    private func sendMessage() {
        let trimmed = inputText.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else { return }
        inputText = ""
        Task { await service.send(trimmed) }
    }

    private func scrollToBottom(proxy: ScrollViewProxy) {
        guard let last = service.messages.last else { return }
        withAnimation(.easeOut(duration: 0.2)) {
            proxy.scrollTo(last.id, anchor: .bottom)
        }
    }
}

Key architectural decisions:

  • .task modifier: Attaches connect() to the view’s SwiftUI task lifetime. When the view disappears, the task is cancelled automatically. The onDisappear is still useful for calling disconnect() explicitly to cancel in-flight receive tasks.
  • LazyVStack inside ScrollView: Renders only visible rows, equivalent to List’s virtualization without the visual constraints a List imposes on bubble layouts.
  • axis: .vertical TextField: Multi-line expansion from iOS 16. The lineLimit(1...5) clamps the height to five lines maximum before the field scrolls internally.

Now update PixarChatApp.swift:

import SwiftUI

@main
struct PixarChatApp: App {
    var body: some Scene {
        WindowGroup {
            ChatView()
        }
    }
}

Checkpoint: Make sure node server.js is running, then build and run (⌘R). You should see the “Pixar Production Chat” navigation title, a green dot in the top-right, and an empty message list with the input bar at the bottom. Type a message and tap Send — the blue bubble should appear on the right with a clock icon, then a single checkmark as the send completes. Open a second simulator or WebSocket client connected to ws://localhost:8080 to see Linguini’s incoming message arrive and the typing indicator animate.

Step 8: Handling Connection State and Reconnection

WebSocket connections drop — Wi-Fi switches, servers restart during deployments, idle timeouts evict the connection. A production chat app must reconnect automatically.

Open Services/ChatService.swift and add the reconnection method inside the existing extension:

func reconnectIfNeeded() {
    guard case .failed = connectionState else { return }

    Task { [weak self] in
        guard let self else { return }

        var delay: Double = 1.0
        let maxDelay: Double = 30.0

        while !Task.isCancelled {
            // Wait before attempting — first retry after 1 s
            try? await Task.sleep(for: .seconds(delay))

            // Tear down the old task before creating a new one
            disconnect()
            connect()

            // Exit the retry loop if we're now connected
            if case .connected = connectionState { break }

            // Exponential backoff capped at 30 s
            delay = min(delay * 2, maxDelay)
        }
    }
}

Exponential backoff prevents a thundering herd problem: if a server restarts and many clients reconnect simultaneously at 1-second intervals, they hammer the server. Staggering retries with growing delays gives the server time to recover.

Warning: In Swift 6, Task { [weak self] in ... } inside a @MainActor-isolated method crosses actor isolation. The [weak self] capture prevents a retain cycle, and guard let self short-circuits cleanly if the service is deallocated during a sleep interval. Always use [weak self] in long-lived Task closures inside ObservableObject classes.

The .onChange handler added in ChatView (Step 7) already calls reconnectIfNeeded() when connectionState transitions to .failed.

The URLSessionWebSocketTask.sendPing() call in connect() (Step 2) prevents the connection from silently timing out between messages. Most NAT gateways and cloud load balancers drop idle TCP connections after 60–120 seconds. A ping every 20 seconds keeps the mapping alive.

Checkpoint: Build and run. With the app connected, stop the Node server (Ctrl-C). The red “Connection lost” banner should slide in. Restart the server — within a few seconds, the banner should animate away and the green dot should return. Send a message immediately after reconnection to confirm the new WebSocket task is fully operational.

Where to Go From Here?

Congratulations! You’ve built PixarChat — a fully functional real-time messaging app featuring WebSocket-powered delivery, custom bubble UI with per-corner radius shapes, animated typing indicators, read receipt upgrades, timestamp grouping, and automatic exponential-backoff reconnection.

Here’s what you learned:

  • How to manage a URLSessionWebSocketTask lifecycle inside a @MainActor-isolated ObservableObject, eliminating manual dispatch calls.
  • How to implement a continuous receive loop with Swift structured concurrency — suspending without blocking a thread, cancelling cleanly with task cancellation.
  • How to use optimistic UI updates to make the app feel instant regardless of round-trip latency.
  • How to build a custom Shape using per-corner RectangleCornerRadii for the bubble tail.
  • How to group messages with a timestamp divider using a time-interval threshold function.
  • How to implement exponential-backoff reconnection tied to SwiftUI’s onChange modifier.

Ideas for extending this project:

  • Image messages: Add a PhotosPicker button to the input bar. For small images, encode as base64 and embed in the body field. For larger images, upload to a CDN and send the URL.
  • Reactions: Long-press a bubble to show a contextMenu with emoji reaction options. Store them as [String: Int] (emoji → count) on ChatMessage and render them as a small overlay.
  • Multiple participants: Replace ChatUser.remoteUser with a dictionary keyed by sender ID. The server would include a sender ID in each frame, and handleFrame would resolve the correct ChatUser from the registry.
  • Actor isolation: Refactor ChatService from @MainActor to a dedicated actor that sends UI updates back to the main actor explicitly. This is the right move for a service with heavy background processing. See Actors in Swift for a deep dive on the trade-offs.
  • Custom view modifiers: Extract the bubble background + shape combination into a reusable ViewModifier so the same style can be applied to image bubbles and other message types without duplication. SwiftUI Custom View Modifiers covers the full pattern.