本記事はコネヒト Advent Calendar 2025の17日目のエントリーになります。
こんにちは、iOSエンジニアのyoshitakaです!
この記事ではAdvent Calendar 2025の13日目のエントリー「API Gateway WebSocket API と Lambda で作る、ママリのリアルタイムチャット機能(サークル機能)を支えるサーバーレスなインフラ設計」で紹介された、ママリのリアルタイムチャット機能について、iOSアプリ開発の視点で知見を共有したいと思います。
はじめに
ママリの「サークル機能」とは、出産育児に関するさまざまなテーマのサークルに参加し、ユーザー同士がチャット形式でコミュニケーションを取れるというものです。


ママリではQ&A形式でユーザー同士がやり取りできる機能を提供していますが、このサークル機能ではよりリアルタイム性を重視しました。
「API Gateway WebSocket API と Lambda で作る、ママリのリアルタイムチャット機能(サークル機能)を支えるサーバーレスなインフラ設計」で詳しく紹介されていますが、リアルタイムチャット機能にはWebSocketを使っています。
WebSocketを使ったリアルタイム通信をSwiftでどのように実装しているのか、具体的なコードとともに紹介します。
WebSocketをiOSアプリで使うには
iOS13以降ではURLSessionWebSocketTaskを実装することで実現できます。
WebSocketの接続と送受信周りの処理を紹介します。 なお、今回のサークル機能では常に1つのコネクションのみを使用する仕様のため、紹介するコードも複数接続は考慮していません。
ハンドシェイク(接続)部分
URLSessionでwebSocketTaskを生成しresumeすることで接続が開始できます。
webSocketTask(with:) | Apple Developer Documentation
func webSocketTask(with request: URLRequest) -> URLSessionWebSocketTask
今回の設計では接続はサークルごとに確立しています。サークルIDをリクエストに含めてwebSocketTaskを作成します。
func setup() { let request = WebSocketRequestProvider.createRequest(circleId: circleId) let session = URLSession( configuration: .default, delegate: self, delegateQueue: nil ) socket = session.webSocketTask(with: request) socket?.resume() }
接続の結果はURLSessionWebSocketDelegateで受け取ります。
URLSessionWebSocketDelegate | Apple Developer Documentation
ハンドシェイクが成功した場合urlSession(_:webSocketTask:didOpenWithProtocol:)が呼ばれるので、接続状態のステートを更新します。
※ 異常系は接続管理部分で書きました
extension CircleChatWebSocketService: URLSessionWebSocketDelegate { nonisolated public func urlSession( _ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String? ) { // 接続成功 Task { @MainActor in connectionState = .connected resetReconnectAttempts() } }
受信部分
無事接続が確立できると、次はWebSocketから値が流れてくるようになります。
値の受け取りはwebSocketTaskのreceive()を使用します。
注意点として、このメソッドは再帰的に呼ぶ必要があります。
func receive() async throws -> URLSessionWebSocketTask.Message
https:// https://developer.apple.com/documentation/foundation/urlsessionwebsockettask/receive()
completionHandler版の方には解説があります。
receive(completionHandler:) | Apple Developer Documentation
WebSocketから受け取る値は、String or Dataの形になります。
startReceivingは初回接続時以外にも接続が切れた際の再接続時には常に呼び出す形になります。
private func startReceiving() { stopReceiving() receiveTask = Task { [weak self] in while !Task.isCancelled { do { guard let webSocket = self?.socket else { break } let message = try await webSocket.receive() switch message { case .string(let string): self?.handleTextMessage(string) case .data(let data): self?.handleBinaryMessage(data) @unknown default: break } } catch { // エラーハンドリング if !Task.isCancelled, self?.connectionState == .connected { try? await Task.sleep(for: .seconds(1)) continue } break } } } } private func stopReceiving() { // 受信待機停止 receiveTask?.cancel() receiveTask = nil }
送信部分
値の送信はwebSocketTaskのsend(_:)を使用します。
func send(_ message: URLSessionWebSocketTask.Message) async throws
send(_:) | Apple Developer Documentation
completionHandler版の方には解説があります
send(_:completionHandler:) | Apple Developer Documentation
送信前には接続を確認しておき、接続が切れている場合は再接続をするように処理を入れました。
public func send(request: CircleWebSocketRequest) async throws {
guard connectionState == .connected else {
switch connectionState {
case .disconnected:
// リクエスト送信前に接続を確認、切断されている場合は再接続トライ
connect()
throw WebSocketError.notConnected
case .connecting:
throw WebSocketError.connecting
default: return
}
}
let dataString = try request.createDataString()
try await socket?.send(.string(dataString))
}
この送信処理では、REST APIとは異なりレスポンスを受け取りません。 つまり、送信したデータをサーバー側が受信したかどうかは別途確認応答を送ってもらう必要があります。
サークル機能ではテキストや写真を投稿できるため、投稿の成功・失敗をユーザーにフィードバックする必要があります。
そこで、すべての送信データにクライアント側でリクエストIDを発行し、そのIDを確認応答で受け取ることで成功・失敗を判定するようにしました。

接続管理
接続が確立してから一定期間送受信がないと接続がタイムアウトしてしまいます。 タイムアウトの時間はサーバー側の設定次第ですが、意図しないタイムアウトを避けるため、pingを一定間隔で送るようにします。
sendPing(pongReceiveHandler:) | Apple Developer Documentation
func setupPingTimer() { pingTimerCancellables.removeAll() Timer.publish(every: pingInterval, on: .main, in: .common) .autoconnect() .sink { [weak self] _ in self?.ping() } .store(in: &pingTimerCancellables) } func ping() { // ping送信前に接続状況を確認、切断されている場合は再接続をトライ if connectionState == .disconnected { connect() } socket? .sendPing(pongReceiveHandler: { error in // 必要に応じてエラーハンドリング }) }
また、意図しない切断が発生した場合は、再接続を行います。
接続が切断される場合は、接続成功時と同じくURLSessionWebSocketDelegateにて切断理由とともに値を受け取ることができます。
urlSession(_:webSocketTask:didCloseWith:reason:) | Apple Developer Documentation
また、電波遮断等が原因の切断を検知できるようにURLSessionTaskDelegateのdidCompleteWithErrorの実装も追加しておくと安心です。
urlSession(_:task:didCompleteWithError:) | Apple Developer Documentation
意図した切断以外は再接続をトライ(遅延を入れて数回行う)するようにしました。
// サーバーからの切断 nonisolated public func urlSession( _ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data? ) { let reasonString = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "No reason" Task { @MainActor in let wasConnected = connectionState == .connected connectionState = .disconnected socket = nil if wasConnected && closeCode != .normalClosure { // 接続状態からの異常切断、再接続を試みる attemptReconnect() } } } // 電波断絶やタイムアウトなど nonisolated public func urlSession( _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error? ) { guard let error else { return } // 接続失敗時の再接続 Task { @MainActor in let wasConnecting = connectionState == .connecting connectionState = .disconnected socket = nil if wasConnecting { attemptReconnect() } } }
おわりに
URLSessionWebSocketTaskを使った具体的なリアルタイムチャット機能の実装方法について紹介しました。
WebSocketを使ったデータのやり取りにおけるリクエスト成功/失敗の管理は想定よりもクライアント側でやることが多く大変でした。
リアルタイムチャット機能の開発ではUI側の実装で苦労した部分も多かったので、別の記事で紹介できればと思います!