WebSocket Support in Tunnelwise: Simplifying Real-Time Testing on macOS

Testing real-time apps locally has always had one annoying step: your WebSocket server runs on localhost, but the client — a mobile device, a co-worker's browser, a staging environment — can't reach it.
Until now, Tunnelwise only forwarded HTTP traffic. That meant developers building WebSocket-based features had to fall back to ngrok or leave testing until deployment. With v1.3.0, Tunnelwise tunnels WebSocket connections too, so you can expose a local Socket.IO server or a Next.js dev server and test against it from anywhere.
This post shows how to set that up with two common stacks: Socket.IO (Node.js) and Next.js.
How WebSocket Tunneling Works
A WebSocket connection starts as an HTTP request — the client sends an Upgrade: websocket header, the server agrees, and the connection switches protocols. Tunnelwise now handles that upgrade correctly, forwarding both the initial HTTP handshake and the subsequent WebSocket frames to your local server.
No extra configuration is needed in Tunnelwise itself. Create a tunnel on the port your app is listening on, and WebSocket connections through that URL will just work.
Testing a Socket.IO App
Socket.IO is one of the most common libraries for WebSocket-based features: live notifications, chat, collaborative editing, multiplayer.
Here's a minimal server:
// server.js
import { createServer } from "http"
import { Server } from "socket.io"
const httpServer = createServer()
const io = new Server(httpServer, {
cors: { origin: "*" },
})
io.on("connection", (socket) => {
console.log("client connected:", socket.id)
socket.on("message", (data) => {
io.emit("message", data) // broadcast to all clients
})
socket.on("disconnect", () => {
console.log("client disconnected:", socket.id)
})
})
httpServer.listen(3001, () => console.log("listening on :3001"))
Start the server, then open Tunnelwise and create a tunnel on port 3001. You'll get a public URL like https://abc123.tunnelwise.io.
In your client, connect to that URL instead of localhost:
import { io } from "socket.io-client"
const socket = io("https://abc123.tunnelwise.io")
socket.on("connect", () => {
console.log("connected:", socket.id)
})
socket.on("message", (data) => {
console.log("received:", data)
})
Socket.IO's transport negotiation handles the WebSocket upgrade automatically. The tunnel is transparent from the library's perspective.
Testing a Next.js App
Next.js adds some configuration requirements when running behind a tunnel, mostly around allowed origins and Content Security Policy.
Allowing the Tunnel Origin
Next.js 15+ validates the Origin header on requests during development. Without explicit configuration, requests coming from *.tunnelwise.io will be blocked.
Add the tunnel domain to allowedDevOrigins in next.config.js:
// next.config.js
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: [
"*.tunnelwise.io",
],
};
export default nextConfig;
This is enough for most cases. If you have a Content Security Policy configured, you'll also need to add the wss://*.tunnelwise.io origin to the connect-src directive so the browser allows outgoing WebSocket connections:
// next.config.js
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: [
"*.tunnelwise.io",
],
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value:
process.env.NODE_ENV === "development"
? "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss: ws://localhost:* wss://localhost:* https://*.tunnelwise.io wss://*.tunnelwise.io; font-src 'self';"
: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; font-src 'self';",
},
],
},
];
},
};
export default nextConfig;
The CSP changes only apply in development (NODE_ENV === "development"), so your production policy stays unchanged.
After updating the config, start next dev and create a tunnel on port 3000. Share the tunnel URL with a device or team member, and WebSocket-dependent features — Next.js Fast Refresh included — will work through the tunnel.
Common Pitfalls
Socket.IO falls back to polling. If the WebSocket connection fails, Socket.IO silently falls back to HTTP long-polling. Check the browser's Network tab and filter by WS to confirm a WebSocket connection was actually established. If you only see HTTP requests, the upgrade is failing somewhere.
The Next.js origin check blocks the request. If you see a 403 or a CORS error when accessing your app through the tunnel, double-check that allowedDevOrigins includes *.tunnelwise.io and that you've restarted the dev server after changing next.config.js.
wss:// vs ws://. Traffic through the tunnel is encrypted — use wss:// (not ws://) when connecting to a tunnelwise.io URL. Socket.IO infers this from the https:// scheme automatically, but if you're constructing WebSocket URLs manually, make sure the protocol matches.
What to Test
A few things worth verifying once you have a tunnel running:
- Connection and disconnection events fire correctly
- Messages are delivered in both directions without corruption
- Reconnection works after the client drops and re-establishes the connection
- Your app handles the tunnel URL the same way it handles
localhost— no hardcodedlocalhostreferences that break in remote testing
Alternatives
ngrok and Cloudflare Tunnel both support WebSockets as well. If you're already using one of those, no reason to switch. Tunnelwise is a macOS-native option if you prefer not to manage a CLI session.