When building real-time media applications—especially in high-stakes environments like remote exam proctoring—supporting mobile devices comes with a unique set of technical hurdles.
Recently, as part of our MeritTrac 360° camera proctoring upgrade, we had to solve a tricky problem: How do you stream and record real-time video feeds from an iPhone client directly to a custom Node.js media server without hitting iOS WebKit security blocks or causing memory bottlenecks?
In this post, I'll walk through the technical challenges we faced with iOS devices, how we handled in-memory binary media buffers, and the complete solution we implemented.
🛑 The Problem: iOS WebKit Restrictions & Buffer Overheads
When extending video streaming and upload support to iOS (Safari/In-App WebKit views), two major roadblocks immediately appeared:
1. The Infamous {"isTrusted": true} Silent WebSocket Rejection
On Desktop browsers or Postman, opening a secure WebSocket (wss://) to a direct server IP works fine if you accept the self-signed warning. iOS WebKit does not give you that option.
If the domain in the WebSocket URL doesn't strictly match the server's SSL certificate (e.g., attempting wss://192.168.1.5:3059/signaling with a *.domain.com certificate), iOS silently drops the connection and returns an unhelpful error object: {"isTrusted": true}.
2. High-Frequency Video Chunk Uploads Overheating Memory
iOS devices record video chunks continuously. Writing incoming media buffers directly to physical disk on the media server for every tick creates high I/O latency, wears down disk health in auto-scaling cloud nodes, and risks memory leaks if stream sequences drop or re-order.
💡 The Architecture & Implementation
To solve this, we combined three architectural patterns:
- Dynamic Wildcard Subdomain Mapping (to satisfy iOS TLS rules).
- In-Memory Buffer Decoding (for secure TLS certificate handling).
- Sequence-Aware Stream Buffer Piping (for real-time recording and upload).
[ iPhone Candidate App ]
│
│ 1. Connects via WSS (Valid Wildcard TLS)
▼
[ Node.js HTTPS Media Server ]
│
├───► [ WebRTC / WebSocket Signaling Engine ]
│ │
│ ▼
└───► [ In-Memory Buffer Chunk Pipeline ] ───► [ S3 / Cloud Storage ]
🛠️ Step 1: Solving iOS SSL Validation in Node.js
Instead of binding our HTTPS server to a raw IP, we decode our production PFX certificate straight into memory out of environment variables and accept incoming streams over a dynamic wildcard subdomain (ip-X-X-X-X.domain.com).
Here is how our Node.js media server bootstrap sets up the secure TLS wrapper:
import * as https from 'https';
import * as express from 'express';
import { InversifyExpressServer } from "inversify-express-utils";
export async function initializeServer() {
const server = new InversifyExpressServer(container);
server.setConfig((app) => {
app.use(express.json({ limit: '10mb' }));
});
const app = server.build();
// 1. Decode PFX certificate directly in-memory (No physical disk footprint)
let credentials;
if (process.env.PFX_BASE64_DATA) {
const pfxBuffer = Buffer.from(process.env.PFX_BASE64_DATA, 'base64');
credentials = {
pfx: pfxBuffer,
passphrase: process.env.PFX_PASSPHRASE
};
} else {
// Fallback for local development
credentials = {
pfx: fs.readFileSync('./local-dev-mock.pfx'),
passphrase: 'local_dev_password'
};
}
// 2. Wrap Express in a native HTTPS server
const httpsServer = https.createServer(credentials, app);
const port = process.env.PORT || 3059;
httpsServer.listen(port, () => {
console.log(`Media Stream Server listening securely on port ${port}`);
});
// 3. Attach WebSockets for real-time signaling
initSocket(httpsServer);
}
🛠️ Step 2: Handling iOS WebSocket Connections & Media Buffer Sequences
When an iPhone opens a stream, it sends media chunks over the WebSocket alongside sequence numbers (seqNo) to ensure proper order during network fluctuations.
Our signaling utility processes incoming frames, updates session timestamps, and routes the stream payload to the WebRTC peer recorder:
import * as https from 'https';
import * as http from 'http';
import * as WebSocket from 'ws';
import * as jwt from 'jsonwebtoken';
export function initSocket(server: http.Server | https.Server) {
const wss = new WebSocket.Server({ server, path: "/signaling" });
wss.on("connection", async (ws: AliveWebSocket, req) => {
ws.isAlive = true;
ws.on("pong", () => (ws.isAlive = true));
try {
// Parse token and sequence metadata from iOS connection URL
const urlParams = new URL(req.url!, "https://x").searchParams;
const token = urlParams.get("token");
let seqNo = Number(urlParams.get("seqNo")) || 1;
const payload: any = jwt.verify(token!, process.env.RP_SIGNING_KEY!);
let clientSession = activeSessions.get(payload.sessionId);
if (clientSession) {
// Re-attach active iOS session on reconnection
clientSession.lastSeen = Date.now();
clientSession.ws = ws;
clientSession.seqNo = seqNo;
if (clientSession.pc.recorder) {
clientSession.pc.recorder.setSequence(seqNo);
}
} else {
// Create new session instance
clientSession = {
ws,
sessionId: payload.sessionId,
seqNo,
lastSeen: Date.now()
};
const pc = await createPeer(payload.sessionId, payload.iceServers, seqNo);
clientSession.pc = pc;
activeSessions.set(payload.sessionId, clientSession);
}
// Incoming Binary/JSON Stream Frames
ws.on("message", async (msg) => {
clientSession.lastSeen = Date.now();
const data = JSON.parse(msg.toString());
switch (data.type) {
case "offer":
await clientSession.pc.setRemoteDescription({ type: "offer", sdp: data.sdp });
const answer = await clientSession.pc.createAnswer();
await clientSession.pc.setLocalDescription(answer);
ws.send(JSON.stringify({ type: "answer", sdp: answer.sdp }));
break;
case "ice-candidate":
await clientSession.pc.addIceCandidate(data.candidate);
break;
case "ping":
ws.send(JSON.stringify({ type: "pong" }));
break;
}
});
} catch (err) {
console.error("iOS Signaling Connection Error:", err);
ws.terminate();
}
});
}
🛠️ Step 3: Fetching the Public IP for Dynamic DNS Matching
To ensure the iPhone connects to wss://ip-X-X-X-X.domain.com (which resolves to the server's external IP while preserving the *.domain.com wildcard cert validation), the server queries its public IPv4 address asynchronously during bootup:
import * as https from 'https';
export function getPublicIpAddress(): Promise<string> {
return new Promise((resolve) => {
const options = {
hostname: 'api.ipify.org',
port: 443,
path: '/',
method: 'GET',
timeout: 3000 // 3-second strict timeout guard
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
const ip = data.trim();
const ipv4Regex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
if (res.statusCode === 200 && ipv4Regex.test(ip)) {
resolve(ip);
} else {
resolve("127.0.0.1"); // Fallback
}
});
});
req.on('error', () => resolve("127.0.0.1"));
req.on('timeout', () => {
req.destroy();
resolve("127.0.0.1");
});
req.end();
});
}
🚀 Key Takeaways
-
iOS WebKit Is Strict with TLS: Never target raw IP addresses over
wss://for iOS clients. Wrap external IPs inside dynamic subdomains that match your wildcard SSL certificate. -
Keep Certs Out of Disk Space: Decode
.pfxor.pemfiles in memory directly from environment variables usingBuffer.from(DATA, 'base64')to keep containers stateless. -
Sequence Buffers for Reconnections: iOS devices frequently drop and reconnect sockets when moving between Wi-Fi and mobile data. Tracking
seqNoon the server allows WebRTC media recorders to stitch video files seamlessly without missing frames.
How are you handling WebRTC/WebSocket media feeds on mobile Safari? Let’s chat in the comments!
Top comments (0)