perf(mobile): compressed websocket frames for iOS#4794

closed5
main
t3code/improve-mobile-data-loading
openedJul 28, 2026byTtheo
View on GitHubTwo-way sync enabledPR last imported 19d ago

iPhones were left out of the websocket compression we shipped in #4705. React Native's iOS WebSocket is backed by SocketRocket, which never offers permessage-deflate, so every frame to the iOS app still crossed the wire uncompressed while Android (OkHttp offers the extension by default) got the win for free.

This adds a small Apple-only Expo module backed by URLSessionWebSocketTask, which offers permessage-deflate in its handshake automatically. A thin JS wrapper exposes the W3C WebSocket surface Effect's Socket.fromWebSocket drives, and the mobile runtime injects it as the Socket.WebSocketConstructor on iOS only. Android and iOS binaries predating the module fall back to the global WebSocket unchanged, so older TestFlight builds keep working.

Details worth noting:

  • maximumMessageSize is raised to 128 MB on the URLSession task. The default receive cap is 1 MB, and socket-fallback snapshot frames can exceed that.
  • On abnormal termination the module synthesizes close code 1006 after the error event, matching browser semantics so the connection supervisor classifies it as a transport error and reconnects.
  • RN's global WebSocket is untouched; only the Effect runtime socket switches implementation.

Tests: vp test run src/lib/nativeWebSocket.test.ts (6 tests: id routing, once-listeners, binary round-trip, idempotent close), mobile typecheck, lint.

Built with Claude Fable 5 in Claude Code.

🤖 Generated with Claude Code


Note

Medium Risk Swaps the transport for all Effect-driven iOS WebSockets (reconnect/close semantics and large-frame handling must stay correct), but scope is limited to mobile relay sockets with explicit fallbacks and unit tests on the JS bridge.

Overview iOS relay WebSockets now use a native URLSessionWebSocketTask path so the client can negotiate permessage-deflate with the server (SocketRocket behind RN’s global WebSocket cannot). Android and older iOS builds without the module keep the existing global WebSocket.

A new Apple-only Expo module (T3WebSocket) manages connections by id, raises the receive cap to 128 MB for large snapshot frames, clears custom URLProtocol classes so dev proxies don’t break upgrades, and on transport failure emits error + synthetic close 1006 for Effect’s reconnect logic. A thin JS NativeWebSocket implements the subset Effect needs (events, send, close, multi-connection routing), and runtime.ts injects it as Socket.WebSocketConstructor when loadNativeWebSocketConstructor() succeeds.

Reviewed by Cursor Bugbot for commit a7adbc1161c1458129d3c8f5eb030c621493054e. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Add compressed WebSocket native module for iOS using URLSessionWebSocketTask

  • Introduces a new native iOS module T3WebSocketModule built on URLSessionWebSocketTask, supporting text/binary messaging, subprotocols, and per-connection events (onOpen, onMessage, onClose, onError) keyed by caller-supplied integer ids.
  • Adds a JS-side NativeWebSocket class that mirrors browser WebSocket semantics (readyState transitions, once listeners, idempotent close, base64↔Uint8Array conversion for binary frames).
  • Updates runtime.ts to dynamically select the native WebSocket constructor on iOS when available, falling back to the global constructor on other platforms.
  • Maximum message size is capped at 128 MiB; abnormal connection termination emits an onClose with code 1006.

Macroscope summarized a7adbc1.

Summary by CodeRabbit

  • New Features

    • Added a native iOS WebSocket implementation for improved mobile connectivity.
    • Supports text and binary messaging, connection lifecycle events, custom protocols, and graceful closure.
    • Automatically uses the native implementation when available, with fallback support to the standard WebSocket.
  • Bug Fixes

    • Improved handling of connection states, errors, close events, and late connection callbacks.
  • Tests

    • Added comprehensive coverage for native WebSocket connections, messaging, listeners, and lifecycle behavior.
Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b9bf6195-023b-4445-b573-5bf5ad15a1f6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/improve-mobile-data-loading

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

apps/mobile/src/lib/nativeWebSocket.tslines 103–103Outdated · fb5b8b9Open thread
……@@ -0,0 +1,204 @@
100+ attachNativeListeners(module);
101+ const protocolList =
102+ typeof protocols === "string" ? [protocols] : protocols ? [...protocols] : [];
103+ module.connect(this.id, url, protocolList);
MmacroscopeappBoton lines 103–103Jul 28, 2026

🟡 Medium lib/nativeWebSocket.ts:103

activeSockets retains an entry for a socket whose constructor threw, so the map accumulates unreachable entries on repeated failed connect calls. The constructor calls activeSockets.set(this.id, this) before module.connect, but there is no cleanup path if connect throws, leaving the entry pinned forever. Wrap the module.connect call in try/catch and delete the entry on failure.

diff
1- module.connect(this.id, url, protocolList);
2+ try {
3+ module.connect(this.id, url, protocolList);
4+ } catch (error) {
5+ activeSockets.delete(this.id);
6+ throw error;
7+ }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/src/lib/nativeWebSocket.ts around line 103:
2
3`activeSockets` retains an entry for a socket whose constructor threw, so the map accumulates unreachable entries on repeated failed `connect` calls. The constructor calls `activeSockets.set(this.id, this)` before `module.connect`, but there is no cleanup path if `connect` throws, leaving the entry pinned forever. Wrap the `module.connect` call in try/catch and delete the entry on failure.

[CLAUDE-FABLE-5] RESPONDING ON BEHALF OF THEO

Fixed in 893035703: connect is wrapped in try/catch and the activeSockets entry is deleted on failure.

apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swiftlines 163–175Outdated · fb5b8b9Open thread
……@@ -0,0 +1,193 @@
172+ self.emit(id: id, event: "onClose", payload: ["code": closeCode.rawValue, "reason": reasonText])
173+ self.remove(id: id, taskIdentifier: webSocketTask.taskIdentifier)
174+ }
175+ }
MmacroscopeappBoton lines 163–175Jul 28, 2026

🟠 High ios/T3WebSocketModule.swift:163

Every normal onClose event is dropped: didCloseWith calls emit (which dispatches asynchronously to the same serial queue) and then immediately calls remove on that queue, deleting emittersById[id] before the queued emit block runs. When the emit block executes, its guard let emitter finds nothing and returns without sending the event. The same ordering in didCompleteWithError drops the synthesized onError and onClose on abnormal termination. The JS side never observes the socket closing and never notifies its supervisor to reconnect. Consider calling remove before emitting but passing the captured emitter directly, or moving the removal into a completion block so it runs after the emit.

suggestion
1 func urlSession(
2 _ session: URLSession,
3 webSocketTask: URLSessionWebSocketTask,
4 didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
5 reason: Data?
6 ) {
7 queue.async {
8 guard let id = self.idsByTaskIdentifier[webSocketTask.taskIdentifier],
9 let emitter = self.emittersById[id] else { return }
10 let reasonText = reason.flatMap { String(data: $0, encoding: .utf8) } ?? ""
11 var payload: [String: Any] = ["code": closeCode.rawValue, "reason": reasonText]
12 payload["id"] = id
13 emitter("onClose", payload)
14 self.remove(id: id, taskIdentifier: webSocketTask.taskIdentifier)
15 }
16 }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift around lines 163-175:
2
3Every normal `onClose` event is dropped: `didCloseWith` calls `emit` (which dispatches asynchronously to the same serial queue) and then immediately calls `remove` on that queue, deleting `emittersById[id]` before the queued `emit` block runs. When the emit block executes, its `guard let emitter` finds nothing and returns without sending the event. The same ordering in `didCompleteWithError` drops the synthesized `onError` and `onClose` on abnormal termination. The JS side never observes the socket closing and never notifies its supervisor to reconnect. Consider calling `remove` before emitting but passing the captured emitter directly, or moving the removal into a completion block so it runs after the emit.

[CLAUDE-FABLE-5] RESPONDING ON BEHALF OF THEO

Real find, fixed in 893035703. Delegate callbacks now emit synchronously on the serial queue (emitLocked) before removing the connection, so close/error events reach JS and the supervisor reconnects.

……@@ -0,0 +1,193 @@
139+ enriched["id"] = id
140+ emitter(event, enriched)
141+ }
142+ }

Close events never reach JS

High Severity

emit always schedules delivery with queue.async, but didCloseWith and didCompleteWithError already run on that serial queue and call remove right after emit. The deferred emit then finds no emitter, so onClose/onError never reach JS. After an open connection drops, Effect never sees the close and cannot reconnect.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit fb5b8b93c7cc7699f7f722849228fc4513464cba. Configure here.

[CLAUDE-FABLE-5] RESPONDING ON BEHALF OF THEO

Same issue Macroscope flagged, fixed in 893035703 — emits now happen synchronously on the queue before removal.

Synced from GitHub

Approvability

Verdict: Needs human review

This PR introduces a new native iOS module for WebSocket handling with compression support. The addition of new native networking infrastructure (~400+ lines of new Swift and TypeScript code) that changes runtime WebSocket behavior warrants human review.

You can customize Macroscope's approvability policy. Learn more.

apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swiftlines 105–114Outdated · 4e70318Open thread
……@@ -0,0 +1,198 @@
111+ self.idsByTaskIdentifier.removeAll()
112+ self.emittersById.removeAll()
113+ }
114+ }
MmacroscopeappBoton lines 105–114Jul 28, 2026

🟡 Medium ios/T3WebSocketModule.swift:105

cancelAll() cancels active tasks but never invalidates the URLSession. Because URLSession retains its delegate (self) until the session is invalidated, the T3WebSocketConnections instance and its URLSession are never deallocated — every destroy/recreate cycle of the Expo module permanently leaks a connections object and session. Call session.finishTasksAndCancel() (or invalidateAndCancel()) in cancelAll() to break the retain cycle.

✅ Resolved in 59ce135d28dffe92b912944d4a903b47a035d2dc

[CLAUDE-FABLE-5] RESPONDING ON BEHALF OF THEO

Correct, fixed in 59ce135d2. Took a slightly different shape than the suggestion: the session is now held in a var that teardown invalidates and nils, so open() builds a fresh session afterwards. finishTasksAndCancel() on a lazy let would break the retain cycle but leave the property pointing at an invalidated session, and any later reconnect on that instance would silently fail.

Verified on the simulator: connects, holds one socket for 60s, and reconnects cleanly after a full app teardown and relaunch.

Added 4 commits

  • fix(mobile): deliver iOS websocket close events and load expo lazily843adad
  • fix(mobile): bypass injected URLProtocol classes on iOS websockets926ff9b
  • fix(mobile): invalidate the iOS websocket URLSession on teardown95f6b12
  • perf(mobile): negotiate permessage-deflate on iOS websocketsc010fc3
Ccursor[bot]reviewed · 1 inline threadJul 29, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 95f6b12918c99c0e6e4898e59ac441b78770a984. Configure here.

apps/mobile/src/lib/nativeWebSocket.tsline 155Outdated · 95f6b12Open thread
……@@ -0,0 +1,218 @@
152+ handleOpen(): void {
153+ this.readyState = NativeWebSocket.OPEN;
154+ this.dispatch("open", { type: "open" });
155+ }

Open overwrites closing readyState

Medium Severity

handleOpen always sets readyState to OPEN, even after close() has already moved the socket to CLOSING. A late native open can revive a socket that was intentionally closed during connect, briefly firing open handlers and confusing Effect’s connection lifecycle.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 95f6b12918c99c0e6e4898e59ac441b78770a984. Configure here.

[CLAUDE-FABLE-5] RESPONDING ON BEHALF OF THEO

Real and reachable, fixed in a7adbc116. Effect closes the socket while still CONNECTING on open-timeout or scope teardown, and the in-flight native connect can still report success afterwards. handleOpen now only transitions from CONNECTING.

Added a regression test that emits a native onOpen after close(). Verified it fails without the guard and passes with it, so it actually covers the case. Simulator still connects and holds a stable socket.

Added 1 commit

  • fix(mobile): ignore a late iOS websocket open after closea7adbc1
CcoderabbitaiBotreviewed · 1 inline threadAug 1, 2026
Synced from GitHub

Actionable comments posted: 1

🧹 Nitpick comments (2)
apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift (1)

30-37: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Report dropped sends instead of returning silently.

sendBinary drops the frame when base64 decoding fails. send (line 95) drops the frame when no task exists for id. The JS adapter receives no signal in either case, so a caller sees a successful send() for data that never left the device. Emit onError on both paths.

♻️ Proposed change
diff
1 Function("sendBinary") { (id: Int, base64: String) in
2- guard let data = Data(base64Encoded: base64) else { return }
3+ guard let data = Data(base64Encoded: base64) else {
4+ self.connections.reportError(id: id, message: "Invalid base64 payload")
5+ return
6+ }
7 self.connections.send(id: id, message: .data(data))
8 }

Add the helper and the missing-task signal in T3WebSocketConnections:

swift
1func reportError(id: Int, message: String) {
2 queue.async { self.emitLocked(id: id, event: "onError", payload: ["message": message]) }
3}
4
5func send(id: Int, message: URLSessionWebSocketTask.Message) {
6 queue.async {
7 guard let task = self.tasksById[id] else {
8 self.emitLocked(id: id, event: "onError", payload: ["message": "Socket is not open"])
9 return
10 }
11 // ... existing send
12 }
13}
🤖 Prompt for AI Agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4In `@apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift` around lines 30
5- 37, Update sendBinary and T3WebSocketConnections.send to report failed sends
6through onError instead of silently returning: use a
7T3WebSocketConnections.reportError helper for invalid base64 input, and emit an
8onError event with “Socket is not open” when no task exists for the requested
9id. Preserve the existing send behavior for valid data and open sockets.
apps/mobile/src/lib/nativeWebSocket.test.ts (1)

106-131: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for onError and for protocol forwarding.

The suite does not exercise handleError or the protocols argument. handleError dispatches an Error instance rather than an event object, which is the least obvious part of the adapter contract. A short test for each closes that gap.

💚 Suggested tests
ts
1it("dispatches native errors and forwards protocols", async () => {
2 const construct = await loadNativeWebSocketConstructor();
3 const socket = construct!("wss://example.test/ws", ["t3"]);
4 expect(lastCall("connect")!.args[2]).toEqual(["t3"]);
5
6 const id = connectionIdOf(socket);
7 const errors: unknown[] = [];
8 socket.addEventListener("error", (event) => errors.push(event));
9 mocks.emit("onError", { id, message: "boom" });
10 expect(errors[0]).toBeInstanceOf(Error);
11});
🤖 Prompt for AI Agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4In `@apps/mobile/src/lib/nativeWebSocket.test.ts` around lines 106 - 131, Add test
5coverage in the native WebSocket test suite for the constructor’s protocols
6argument and the onError path handled by handleError. Instantiate the socket
7with a protocol list and assert connect receives it, then emit onError, capture
8the error event, and assert the dispatched value is an Error instance.
🤖 Prompt for all review comments with AI agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4Inline comments:
5In `@apps/mobile/src/lib/nativeWebSocket.ts`:
6- Around line 46-63: Update attachNativeListeners so nativeListenersAttached is
7set to true only after all four module.addListener registrations complete
8successfully. Keep the flag false when any registration throws, allowing a later
9invocation to retry attaching listeners and deliver events to sockets.
10
11---
12
13Nitpick comments:
14In `@apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift`:
15- Around line 30-37: Update sendBinary and T3WebSocketConnections.send to report
16failed sends through onError instead of silently returning: use a
17T3WebSocketConnections.reportError helper for invalid base64 input, and emit an
18onError event with “Socket is not open” when no task exists for the requested
19id. Preserve the existing send behavior for valid data and open sockets.
20
21In `@apps/mobile/src/lib/nativeWebSocket.test.ts`:
22- Around line 106-131: Add test coverage in the native WebSocket test suite for
23the constructor’s protocols argument and the onError path handled by
24handleError. Instantiate the socket with a protocol list and assert connect
25receives it, then emit onError, capture the error event, and assert the
26dispatched value is an Error instance.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 13b4bc2a-5a6e-4d6f-9422-ebf4f5d26330

📥 Commits

Reviewing files that changed from the base of the PR and between 694f8d1c6eaaabafbf5c2861ae524174919ef625 and a7adbc1161c1458129d3c8f5eb030c621493054e.

📒 Files selected for processing (6)
  • apps/mobile/modules/t3-websocket/expo-module.config.json
  • apps/mobile/modules/t3-websocket/ios/T3WebSocket.podspec
  • apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift
  • apps/mobile/src/lib/nativeWebSocket.test.ts
  • apps/mobile/src/lib/nativeWebSocket.ts
  • apps/mobile/src/lib/runtime.ts
……@@ -0,0 +1,226 @@
60+ module.addListener("onError", (payload) => {
61+ activeSockets.get(payload.id)?.handleError(payload.message ?? "WebSocket error");
62+ });
63+}
CcoderabbitaiBoton lines 46–63Aug 1, 2026

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Set the attached flag only after the listeners attach.

If the first module.addListener call throws, nativeListenersAttached stays true. No later socket then receives native events, and every socket stays in CONNECTING until Effect's open timeout.

🛡️ Proposed change
diff
1 if (nativeListenersAttached) {
2 return;
3 }
4- nativeListenersAttached = true;
5 module.addListener("onOpen", (payload) => {
6@@
7 module.addListener("onError", (payload) => {
8 activeSockets.get(payload.id)?.handleError(payload.message ?? "WebSocket error");
9 });
10+ nativeListenersAttached = true;
11 }
📝 Committable suggestion

‼️ IMPORTANT Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

suggestion
1function attachNativeListeners(module: T3WebSocketNativeModule) {
2 if (nativeListenersAttached) {
3 return;
4 }
5 module.addListener("onOpen", (payload) => {
6 activeSockets.get(payload.id)?.handleOpen();
7 });
8 module.addListener("onMessage", (payload) => {
9 activeSockets.get(payload.id)?.handleMessage(payload.data ?? "", payload.binary === true);
10 });
11 module.addListener("onClose", (payload) => {
12 activeSockets.get(payload.id)?.handleClose(payload.code ?? 1006, payload.reason ?? "");
13 });
14 module.addListener("onError", (payload) => {
15 activeSockets.get(payload.id)?.handleError(payload.message ?? "WebSocket error");
16 });
17 nativeListenersAttached = true;
18}
🤖 Prompt for AI Agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4In `@apps/mobile/src/lib/nativeWebSocket.ts` around lines 46 - 63, Update
5attachNativeListeners so nativeListenersAttached is set to true only after all
6four module.addListener registrations complete successfully. Keep the flag false
7when any registration throws, allowing a later invocation to retry attaching
8listeners and deliver events to sockets.

Closing as part of the open-PR backlog sweep (wave 1).

Reason: iOS native compressed WS; server permessage-deflate already shipped in #4705. Related: #4705

Reopen if this is still wanted and you’re willing to rebase onto current main.