In earlier posts, I covered push notifications. In this one, I'll show how to add real-time communication via websockets between an Expo client and a NestJS server. The demo repo I use is https://github.com/amarjanica/firebase-expo-demo, it also has push notifications setup.

Why Websockets?

Http is one-way. Websockets keep a connection open, so both client and server can send messages:

Server Client 2-way communication with websockets
Server Client 2-way communication with websockets

Handshake is actualy an http upgrade request, and you can include an auth token during handshake. If handshake succeeds, connection stays open until one of the side closes it, or the client goes to background or loses connectivity. If Websockets aren't doable, fallback to long-polling. In long polling, the client makes a request, server holds it until data or timeout, client fires another and repeat.

Connection Health and Security

Many load balancers close inactive connections after some time (60ish seconds). To fix that, send a heartbeat. If you use socket.io, it's built-in.

Keep in mind that each websocket connection takes resources on the server. One form of attack would be opening many connections, other rapid connecting and disconnecting, sending large messages etc.

My approach would be to only allow authenticated users and limit the connection per each user. If you don't support authentication, at least limit by ip.

Client Implementation (Expo/React Native/Web)

I'll use socket.io-client because it handles reconnections, heartbeats and fallback to polling:

npm i --save socket.io-client

import { io } from 'socket.io-client';

export default function useWebsockets() {
  const { user } = useAuthStore(); // hook that handles firebase user state
  const socketRef = useRef(null);

  const initSocket = useCallback(async () => {
    if (socketRef.current) {
      socketRef.current.disconnect();
    }
    const token = await user?.getIdToken();

    const socket = io(process.env.EXPO_PUBLIC_BACKEND_URL, {
      query: {
        token,
      },
      reconnection: true,
      reconnectionAttempts: 30,
      reconnectionDelay: 1000,
      reconnectionDelayMax: 25000,
      randomizationFactor: 0.5,
    });
    socketRef.current = socket;

    socket.on('message', (message: YourTypedMessage) => {
      logger.debug('Got a message', message);
    });
    socket.on('connect_error', (err) => {
      logger.debug('Connect error occured', err);
    });
    socket.on('connect', function () {
      logger.debug('connected');
    });
    socket.on('disconnect', function () {
      logger.debug('disconnect');
    });
  }, [user]);

  useEffect(() => {
    void initSocket();

    const sub = AppState.addEventListener('change', (state) => {
      if (state === 'active') {
        void initSocket();
      } else if (state === 'background') {
        socketRef.current?.disconnect();
        socketRef.current = null;
      }
    });

    return () => {
      sub.remove();
      socketRef.current?.disconnect();
      socketRef.current = null;
    };
  }, [initSocket]);
}

I pass the auth token in auth , you could also use query but something about passing a JWT token in a query doesn't smell right.

On app background, I prefer to teardown the socket, and reconnect when it returns to foreground. This hook works on native and web.

Server Implementation in NestJS

Install the dependencies:

npm i --save @nestjs/websockets @nestjs/platform-socket.io

My NestJS websocket implementation is divided in 2 parts:

  • Authorizing
  • Processing of messages

I want to do authorize checks before I approve a new connection. The right place is a custom IoAdapter.

export class AuthIoAdapter extends IoAdapter {
  private userConnections = new Map<string, Set<string>>();
  private readonly MAX_CONNECTIONS = 3;
  
  constructor(
    appOrHttpServer: INestApplicationContext,
    private readonly firebaseService: FirebaseService,
  ) {
    super(appOrHttpServer);
  }

  createIOServer(port: number, options?: any): any {
    const server: Server = super.createIOServer(port, options);

    server.use(async (socket: Socket, next) => {
      const token = socket.handshake.auth?.token;
      if (!token) return next(new Error('Unauthorized'));
      if (typeof token !== 'string') return next(new Error('Unauthorized'));
      try {
        const userId = await this.verifyToken(token);
        const connections = this.userConnections.get(userId) || new Set<string>();

        if (connections.size >= this.MAX_CONNECTIONS) {
          return next(new Error('Too many connections'));
        }

        connections.add(socket.id);
        this.userConnections.set(userId, connections);

        socket.data.userId = userId;
        socket.on('disconnect', () => {
          connections.delete(socket.id);
          if (connections.size === 0) {
            this.userConnections.delete(userId);
          }
        });

        return next();
      } catch (err) {
        return next(new Error('Unauthorized'));
      }
    });

    return server;
  }

In this check I expect a token to be sent in the handshake, I expect it to be a valid firebase token, and I don't allow more than 3 connections per user. That's why I need to keep track of userConnections.

I wire this adapter in main.ts:

const app = await NestFactory.create(AppModule);
const configService = app.get(EnvConfigService);
app.useWebSocketAdapter(new AuthIoAdapter(app, app.get(FirebaseService)));

Websocket Gateway for messaging

import { ConnectedSocket, MessageBody, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Socket, Server } from 'socket.io';
import { ConnectionInfo } from './types';

@WebSocketGateway({
  cors: {
    origin: ['http://localhost:8081'],
  },
  pingInterval: 5000,
  pingTimeout: 5000,
  maxHttpBufferSize: 1e6,
  transports: ['websocket'],
})
export class EventGateway {
  @WebSocketServer()
  server: Server;
  private userConnections = new Map<string, ConnectionInfo[]>();

  handleConnection(client: Socket) {
    try {
      // save to userConnections and notify user
    } catch (error) {
      client.disconnect(true);
      throw error;
    }
  }

  handleDisconnect(client: Socket) {
    // remove a connection and notify user
  }
}

I keep a list of userConnections in the event gateway because I want to do real time updates. It might seem like a duplication in event adapter, but those entries serve for different purposes.

I can call EventGateway from other controllers and services, and do an update when one of the resources changes. My update function inside the gateway is:

  notifyUser<T>(userId: string, message: string, payload: T) {
    if (!userId) return;
    const connections = this.userConnections.get(userId) || [];
    connections.forEach((conn) => {
      this.server.to(conn.socketId).emit(message, payload);
    });
  }

And one example of a call is from my app controller where I send a greeting message...

@Controller()
export class AppController {
  constructor(
    private eventGateway: EventGateway,
  ) {}

  @Get('hello')
  getHello(@Req() req: Request): string {
    const message = `${req.user.name} says hello!`;
    this.eventGateway.notifyUser(req.user.uid, 'greeting', message);
    return 'Message sent!';
  }
}

My client would need to register a greeting to process it:

    socket.on('greeting', (message: string) => {
      Alert.alert('Greeting from server', message);
    });

Send a Message from the Client

Messages that are sent from the client, need to be defined in the EventGateway with SubscribeMessage decorator. For example, client can request a disconnect for one of his connections:

  @SubscribeMessage('disconnectRequest')
  handleDisconnectRequest(@ConnectedSocket() client: Socket, @MessageBody() targetSocketId: string) {
    const userId = client.data.userId as string;
    const connections = this.userConnections.get(userId) || [];
    const isOwner = connections.some((c) => c.socketId === client.id);
    if (isOwner) {
      const target = this.server.sockets.sockets.get(targetSocketId);
      if (target) {
        target.disconnect(true);
        this.notifyUserConnections(userId);
      }
    }
  }

That’s it. You now have authenticated, real-time communication between Expo and NestJS, with reconnection, connection limits, and support for multiple sessions.

Youtube: https://youtu.be/uu8qfUdoYCQ

Github: https://github.com/amarjanica/firebase-expo-demo