Frontend unable to connect to Render backend with HTTP and Websockets

Stack:
Frontend: Next.js
Backend: Express/Node.js

const express = require('express');
const { createClient } = require('@supabase/supabase-js');
const cors = require('cors');
const http = require('http');
const { WebSocketServer } = require('ws');
const WebSocket = require('ws');
global.WebSocket = WebSocket; // so I can use in my http routes

// Create the Express app
const app = express();

// Create an HTTP server from the Express app
const server = http.createServer(app);

// Create WebSocket server
const wss = new WebSocketServer({ noServer: true });

// Database stuff

app.use(cors({
  origin: '*',   // or '*' for all origins
  methods: ['GET', 'POST'],
}));
app.use(express.json());

// Routes and other stuff

// Store connected clients and their rooms
const clients = new Map();
const rooms = new Map();

// WebSocket connection handling
wss.on('connection', (ws, req) => {
  const id = Math.random().toString(36).substring(2, 10);
  console.log('A user connected:', id);
  
  // Store client with ID
  clients.set(ws, { id });
  
  // Send initial connection confirmation
  ws.send(JSON.stringify({
    type: 'connected',
    id
  }));
  
  // Handle messages
  ws.on('message', (message) => {
    try {
      const data = JSON.parse(message.toString());
      console.log(`Received message from ${id}:`, data.type);
      
      // Handle different message types
      switch (data.type) {
        case 'joinDraft':
          const draftId = data.draftId;
          // Add client to room
          if (!rooms.has(draftId)) {
            rooms.set(draftId, new Set());
          }
          rooms.get(draftId).add(ws);
          clients.get(ws).room = draftId;
          console.log(`Client ${id} joined draft room: ${draftId}`);
          
          // Send confirmation
          ws.send(JSON.stringify({
            type: 'joinedDraft',
            draftId,
            clientCount: rooms.get(draftId).size
          }));
          
          // Notify others in the room
          broadcastToRoom(draftId, {
            type: 'userJoined',
            clientId: id,
            clientCount: rooms.get(draftId).size
          }, ws);
          break;
          
        case 'leaveDraft':
          const roomToLeave = clients.get(ws).room;
          if (roomToLeave && rooms.has(roomToLeave)) {
            const clientCount = rooms.get(roomToLeave).size - 1;
            
            // Notify others before removing
            broadcastToRoom(roomToLeave, {
              type: 'userLeft',
              clientId: id,
              clientCount: clientCount
            }, ws);
            
            rooms.get(roomToLeave).delete(ws);
            console.log(`Client ${id} left draft room: ${roomToLeave}`);
            
            // Clean up empty rooms
            if (rooms.get(roomToLeave).size === 0) {
              rooms.delete(roomToLeave);
              console.log(`Room ${roomToLeave} deleted (empty)`);
            }
            
            // Remove room from client info
            delete clients.get(ws).room;
          }
          break;
          
        case 'ping':
          ws.send(JSON.stringify({
            type: 'pong',
            timestamp: Date.now()
          }));
          break;
          
        default:
          // Broadcast to room if client is in a room
          const room = clients.get(ws).room;
          if (room && rooms.has(room)) {
            broadcastToRoom(room, data, ws);
          }
      }
    } catch (error) {
      console.error('Error processing message:', error);
      ws.send(JSON.stringify({
        type: 'error',
        message: 'Failed to process message'
      }));
    }
  });
  
  // Handle disconnection
  ws.on('close', () => {
    const clientInfo = clients.get(ws);
    console.log('User disconnected:', clientInfo?.id);
    
    // Remove from rooms and notify others
    if (clientInfo?.room && rooms.has(clientInfo.room)) {
      const roomId = clientInfo.room;
      const clientCount = rooms.get(roomId).size - 1;
      
      // Notify others in the room
      broadcastToRoom(roomId, {
        type: 'userDisconnected',
        clientId: clientInfo.id,
        clientCount: clientCount
      }, ws);
      
      rooms.get(roomId).delete(ws);
      
      // Clean up empty rooms
      if (rooms.get(roomId).size === 0) {
        rooms.delete(roomId);
        console.log(`Room ${roomId} deleted (empty after disconnect)`);
      }
    }
    
    // Remove client
    clients.delete(ws);
  });
  
  // Handle errors
  ws.on('error', (error) => {
    console.error(`WebSocket error for client ${id}:`, error);
  });
});

// Helper function to broadcast to a room (excluding sender if provided)
function broadcastToRoom(roomId, message, excludeClient = null) {
  if (rooms.has(roomId)) {
    const roomClients = rooms.get(roomId);
    
    roomClients.forEach((client) => {
      if (client !== excludeClient && client.readyState === WebSocket.OPEN) {
        try {
          client.send(JSON.stringify(message));
        } catch (error) {
          console.error('Error sending message to client:', error);
        }
      }
    });
    
    return true;
  }
  return false;
}

// Make broadcast function available to routes
app.set('broadcastToRoom', broadcastToRoom);

// Ping clients periodically to keep connections alive
setInterval(() => {
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() }));
    }
  });
}, 30000); // Every 30 seconds

const port = process.env.PORT || 3000;

server.on('upgrade', (req, socket, head) => {
  wss.handleUpgrade(req, socket, head, (ws) => {
    wss.emit('connection', ws, req)
  })
})

server.listen(port, () => {
  console.log(`App listening on port ${port}`);
});

Frontend:

useEffect(() => {
      
    // Using the correct endpoint with the WebSocket path
    const ws = new WebSocket('wss://*myurlhere*.onrender.com/');
    
    // Add connection timeout handling
    const connectionTimeout = setTimeout(() => {
      if (ws.readyState !== WebSocket.OPEN) {
        console.error('WebSocket connection timed out');
        ws.close();
      }
    }, 10000); // 10 second timeout
    
    ws.onopen = () => {
      console.log('Connected to WebSocket server');
      clearTimeout(connectionTimeout);
      setIsConnected(true);
      setSocket(ws);
      
      // Send a ping to keep the connection alive
      const pingInterval = setInterval(() => {
        if (ws.readyState === WebSocket.OPEN) {
          ws.send(JSON.stringify({ type: 'ping' }));
        }
      }, 30000); // Every 30 seconds
      
      // Clear interval on component unmount
      return () => clearInterval(pingInterval);
    };
  
    ws.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        console.log('Received message:', data);
        // Handle different message types here
      } catch (error) {
        console.error('Error parsing message:', error);
      }
    };
  
    ws.onerror = (error) => {
      console.error('WebSocket error:', error);
      console.log('Connection state:', ws.readyState);
      console.error('Connection failed. Please check if the server is running and accessible.');
      // Try reconnecting with a fallback URL if needed
      // attemptReconnect();
    };
  
    ws.onclose = (event) => {
      console.log(`WebSocket closed: ${event.code} ${event.reason}`);
      setIsConnected(false);
      clearTimeout(connectionTimeout);
    };
  
    return () => {
      clearTimeout(connectionTimeout);
      if (ws.readyState === WebSocket.OPEN) {
        ws.close();
      }
    };
  }, []);

Connecting to the render backend works when I use npx wscat -c wss:.. but as soon as I use my frontend, it can’t connect to the websocket server. HTTP works fine. Anybody know what the issue might be?