Real-Time Messaging with Socket.IO

Build a WebSocket messaging backend with JWT auth, rooms, and live broadcasts.

Introduction

30 Second Summary

Think about how messaging apps deliver texts the instant you hit send. That split-second delivery relies on a persistent connection between your device and the server.

In this project, you will build a real-time messaging backend that persists chat messages to a database and broadcasts them instantly to all conversation participants using WebSocket connections.

What You'll Build

A Socket.IO-powered messaging layer integrated with your existing Express and PostgreSQL auth backend, complete with automated integration tests.

By the end of this project, you'll have:

  • A Socket.IO server that authenticates WebSocket connections using your existing JWT cookies.
  • REST endpoints for creating conversations and sending messages, with real-time broadcasting to all participants.
  • An automated integration test suite proving your endpoints and real-time Socket.IO events work correctly.
  • Secret Mission: implement read receipts that broadcast when a user has seen a message.

Are there any prerequisites?

Yes. This project builds directly on Project 1 (Auth Backend). You need your Docker containers running with the existing schema (conversations, participants, messages, and message_reads tables) already populated.

Set Up Socket.IO on Your Server

Your auth backend handles user registration, login, and JWT verification over REST. Now it's time to add real-time capabilities so your server can push messages instantly to connected clients without them needing to refresh.

Socket.IO is a library that enables bidirectional, event-based communication between your server and connected browsers. In this step, you'll install it and refactor your server so it can handle both REST requests and WebSocket connections on the same port.

In this step, get ready to:
  • Install Socket.IO as a project dependency.
  • Refactor your server to use Node's HTTP server with Socket.IO attached.
  • Create a socket setup module that logs new connections.
Install the Socket.IO package
  • Install Socket.IO by running this command in your project root:
npm install socket.io@4.8.3
  • Verify it was added by checking your package.json. You should see "socket.io": "^4.8.3" listed under dependencies.

Install command failing?

Make sure you're running the command from the same directory that contains your package.json file. If you see permission errors, try closing and reopening your terminal.

help me fix my npm install error

Refactor your server for WebSocket support

Your current server.js uses app.listen() to start Express directly. Socket.IO needs access to the underlying HTTP server, so you'll wrap your Express app in Node's createServer() and attach Socket.IO to it.

  • Replace the entire contents of src/server.js with the following code:
require("dotenv").config();
const express = require("express");
const { createServer } = require("node:http");
const { Server } = require("socket.io");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const authRoutes = require("./routes/auth");
const { pool } = require("./config/db");
const { setupSocket } = require("./socket");

const app = express();
// Wrap Express in a raw HTTP server so Socket.IO can share the same port
const httpServer = createServer(app);
const PORT = process.env.PORT || 5000;

const corsOptions = {
  origin: process.env.CLIENT_URL || "http://localhost:3000",
  credentials: true,
};

// CORS configuration
app.use(cors(corsOptions));

// Parse JSON requests and cookies
app.use(express.json());
app.use(cookieParser());

// Serve static files for test client
app.use(express.static("public"));

// Health check
app.get("/api/health", (req, res) => {
  res.json({ success: true, message: "Server is running" });
});

// Routes
app.use("/api/auth", authRoutes);

// Socket.IO setup
const io = new Server(httpServer, {
  cors: corsOptions,
});

// Store io on the app so controllers can emit events later
app.set("io", io);
setupSocket(io);

// Start the server only if not in test mode
if (process.env.NODE_ENV !== "test") {
  httpServer.listen(PORT, async () => {
    try {
      const client = await pool.connect();
      console.log("Connected to PostgreSQL database");
      client.release();
    } catch (err) {
      console.error("Error connecting to PostgreSQL database", err);
    }
    console.log(`Server is running on port ${PORT}`);
  });
}

module.exports = { app, httpServer, io };

What does this code do?

  • createServer(app) wraps your Express app in a raw Node.js HTTP server. Socket.IO attaches to this HTTP server to intercept WebSocket upgrade requests.
  • new Server(httpServer, { cors: corsOptions }) creates the Socket.IO server instance, sharing the same CORS config as your REST API.
  • app.set("io", io) stores the Socket.IO instance on Express so your route controllers can access it later to broadcast events.
  • express.static("public") serves static files from a public/ folder. You'll use this for a debug test page later.
  • The NODE_ENV !== "test" guard prevents the server from auto-starting when imported in test files. Tests start their own server on a random port.
  • module.exports = { app, httpServer, io } exports all three so integration tests can access them directly.

You won't see a change yet. The next substep creates the socket/index.js module this file imports.

✔️ Awesome, I've got everything!

Great. Make sure you've saved src/server.js before continuing.

ⓧ I'd like to double check the full code

require("dotenv").config();
const express = require("express");
const { createServer } = require("node:http");
const { Server } = require("socket.io");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const authRoutes = require("./routes/auth");
const { pool } = require("./config/db");
const { setupSocket } = require("./socket");

const app = express();
const httpServer = createServer(app);
const PORT = process.env.PORT || 5000;

const corsOptions = {
  origin: process.env.CLIENT_URL || "http://localhost:3000",
  credentials: true,
};

app.use(cors(corsOptions));
app.use(express.json());
app.use(cookieParser());
app.use(express.static("public"));

app.get("/api/health", (req, res) => {
  res.json({ success: true, message: "Server is running" });
});

app.use("/api/auth", authRoutes);

const io = new Server(httpServer, {
  cors: corsOptions,
});

app.set("io", io);
setupSocket(io);

if (process.env.NODE_ENV !== "test") {
  httpServer.listen(PORT, async () => {
    try {
      const client = await pool.connect();
      console.log("Connected to PostgreSQL database");
      client.release();
    } catch (err) {
      console.error("Error connecting to PostgreSQL database", err);
    }
    console.log(`Server is running on port ${PORT}`);
  });
}

module.exports = { app, httpServer, io };
Create the socket setup module

Keeping socket logic in its own module makes it easier to add authentication and room management later without cluttering your main server file.

  • Create the src/socket/ directory and a new index.js file inside it by running:
mkdir src/socket
touch src/socket/index.js
  • Confirm the file was created by running:
ls src/socket/

You should see index.js in the output.

  • Add the following code to src/socket/index.js:
function setupSocket(io) {
  // Log every new WebSocket connection
  io.on("connection", (socket) => {
    console.log("New connection:", socket.id);
  });
  return io;
}

module.exports = { setupSocket };

What does this code do?

The setupSocket function accepts the Socket.IO server instance and registers a listener for the connection event. Every time a client establishes a WebSocket connection, it logs the unique socket.id (a random 20-character identifier assigned to each connection).

Exporting this function keeps your socket logic separate from your Express routing. You'll expand it with authentication and room-joining logic in the next step.

  • Save src/socket/index.js.
  • Start your server by running:
npm run dev
  • Open http://localhost:5000/api/health in your browser. You should see the JSON response {"success":true,"message":"Server is running"} confirming your REST API still works.
  • Open your browser's DevTools (Cmd+Option+I on macOS or Ctrl+Shift+I on Windows).
  • Switch to the Network tab.
  • Visit http://localhost:5000 (the root URL). Look for requests to /socket.io/ in the Network tab. You should also see New connection: followed by a random ID in your terminal.

Why does the browser connect automatically?

Socket.IO's server automatically serves a client library at /socket.io/socket.io.js. When your browser hits the root URL, it attempts the default Socket.IO handshake. The server responds with a session ID and upgrades the connection from HTTP long-polling to a WebSocket.

Not seeing the connection log?

Make sure your src/socket/index.js file is saved. If your server failed to start, check for a MODULE_NOT_FOUND error in the terminal. This means the file path doesn't match. Verify your directory structure is src/socket/index.js (not src/socket.js).

help me debug why my Socket.IO server isn't logging connections

✔️ Awesome, I've got everything!

Great. Make sure both files are saved and your server is running without errors.

ⓧ I'd like to double check the full code

function setupSocket(io) {
  io.on("connection", (socket) => {
    console.log("New connection:", socket.id);
  });
  return io;
}

module.exports = { setupSocket };

Your server now speaks two protocols on the same port. REST requests hit your Express routes as usual, while WebSocket connections are handled by Socket.IO. Next up, you'll wire your existing JWT authentication into Socket.IO so only logged-in users can connect.

Authenticate WebSocket Connections

Your Socket.IO server accepts connections from anyone right now. That means any browser or script can open a persistent WebSocket to your backend without proving who they are.

In this step, you'll wire your existing JWT verification into Socket.IO's middleware layer. Every incoming connection will need a valid accessToken cookie before the server lets it through. Authenticated users will also be auto-joined to their conversation rooms so they receive real-time events immediately on connect.

In this step, get ready to:
  • Add JWT authentication middleware to your Socket.IO server.
  • Auto-join connected users to their personal and conversation rooms.
  • Test that unauthenticated connections are rejected.
Add JWT authentication middleware to Socket.IO

Socket.IO supports namespace middleware via io.use(). This function runs before every incoming connection is promoted to the connection event. If the middleware calls next() with an error, the connection is refused and the client receives a connect_error event.

  • In src/socket/index.js, replace the entire file contents with the following code:
const jwt = require("jsonwebtoken");
const cookieParser = require("cookie-parser");
const { pool } = require("../config/db");

function setupSocket(io) {
  // Parse cookies on the engine level (runs for every HTTP request)
  io.engine.use(cookieParser());

  // Authentication middleware
  io.use((socket, next) => {
    const token = socket.request.cookies?.accessToken;
    if (!token) {
      return next(new Error("Authentication error"));
    }
    try {
      const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
      socket.user = decoded;
      next();
    } catch (err) {
      next(new Error("Authentication error"));
    }
  });

What does this code do?

This file now handles authentication at two levels before a WebSocket connection is allowed through.

  • io.engine.use(cookieParser()) attaches cookie parsing to the underlying Engine.IO HTTP layer. This runs on every HTTP request, including the WebSocket handshake, so cookies are available on socket.request.cookies.
  • io.use() registers a namespace middleware that fires for every new connection attempt. It reads the accessToken cookie, verifies it with your existing JWT secret, and attaches the decoded payload (containing the user's ID) to socket.user.
  • If the token is missing or invalid, next(new Error("Authentication error")) rejects the connection before it ever reaches the connection handler.

Why reuse httpOnly cookies instead of a token header?

Your auth backend from Project 1 already stores JWTs in httpOnly cookies. This makes them invisible to client-side JavaScript, which protects against XSS attacks.

By parsing the same cookie in Socket.IO's engine layer, you get authenticated WebSocket connections with zero changes to your existing auth system. The browser automatically sends the cookie on the WebSocket handshake request.

You won't see a change yet. The file is incomplete until you add the connection handler in the next section.

Auto-join users to their conversation rooms on connect

Once a user passes authentication, they need to be placed in the correct Socket.IO rooms to receive events for their conversations. Rooms are Socket.IO's built-in mechanism for broadcasting to a subset of connected clients.

  • Directly below the io.use() middleware you just added (still inside setupSocket), add the connection handler, the return statement, and the module export:
  io.on("connection", async (socket) => {
    console.log(`User connected: ${socket.user.id}`);

    // Join user-specific room for direct notifications
    socket.join(`user:${socket.user.id}`);

    // Auto-join user to their conversation rooms
    try {
      const result = await pool.query(
        "SELECT conversation_id FROM participants WHERE user_id = $1",
        [socket.user.id]
      );
      result.rows.forEach((row) => {
        socket.join(`conversation:${row.conversation_id}`);
      });
      console.log(`User ${socket.user.id} joined ${result.rows.length} conversation rooms`);
    } catch (err) {
      console.error("Error joining rooms:", err);
    }

    socket.on("disconnect", () => {
      console.log(`User disconnected: ${socket.user.id}`);
    });
  });

  return io;
}

module.exports = { setupSocket };

What does this code do?

  • socket.join(`user:${socket.user.id}`) places the user in a personal notification room. Later, when someone creates a conversation with them, you'll emit events to this room so they receive them instantly.
  • The pool.query fetches all conversation IDs from the participants table where the user is a member. The socket then joins each conversation:{id} room so it receives messages broadcast to any of those conversations.
  • The disconnect listener logs when a user leaves. Socket.IO automatically removes the socket from all rooms on disconnect.
  • Save src/socket/index.js.
  • Restart your server by pressing Ctrl+C in the terminal to stop it, then run:
node src/server.js
  • You should see Server is running on port 5000 and Connected to PostgreSQL database in your terminal with no errors.

Server crashing on start?

  • Check that your require paths are correct. The pool import uses ../config/db (going up one directory from src/socket/).
  • Make sure cookieParser is imported from the cookie-parser package (already installed from Project 1), not defined as a function.
  • Verify your .env file has ACCESS_TOKEN_SECRET defined. The JWT verification will fail silently on startup but crash on the first connection attempt if this is missing.

Help me debug my Socket.IO authentication middleware

Test authenticated and unauthenticated connections

Socket.IO serves its client library automatically at /socket.io/socket.io.js. You can load this in a browser and attempt a connection to verify your middleware rejects unauthenticated users.

  • Open your browser and navigate to http://localhost:5000.
  • Open DevTools by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • In the Console tab, load the Socket.IO client and attempt a connection by pasting this code:
const script = document.createElement('script');
script.src = '/socket.io/socket.io.js';
script.onload = () => {
  const socket = io();
  socket.on('connect_error', (err) => {
    console.log('Rejected:', err.message);
  });
};
document.head.appendChild(script);
  • You should see Rejected: Authentication error printed in the browser console. This confirms your middleware is rejecting connections that don't have a valid accessToken cookie.
  • Switch to your server terminal. You should NOT see a "User connected" log, confirming the connection never reached the connection handler.

How do I test an authenticated connection?

To test a successful connection, you would need to log in first (via POST /api/auth/login) so the browser has the accessToken cookie set. You'll get a full end-to-end test of authenticated connections in Step 5 when you write automated tests with socket.io-client.

For now, confirming that unauthenticated connections are rejected proves your middleware is working correctly. If it rejects invalid tokens, it will accept valid ones.

Not seeing the rejection message?

  • Make sure your server is running. Check the terminal for the "Server is running on port 5000" message.
  • Verify you're loading the script from the same origin (http://localhost:5000), not from a file or different port. The Socket.IO client needs to connect to where your server is listening.
  • If you see a CORS error, confirm you opened http://localhost:5000 directly, not http://localhost:3000.

Help me test my Socket.IO authentication

✔️ Awesome, I've got everything!

Great. Double check you've saved src/socket/index.js and your server restarts cleanly.

ⓧ I'd like to double check the full code

Here is the complete src/socket/index.js file after this step:

const jwt = require("jsonwebtoken");
const cookieParser = require("cookie-parser");
const { pool } = require("../config/db");

function setupSocket(io) {
  // Parse cookies on the engine level (runs for every HTTP request)
  io.engine.use(cookieParser());

  // Authentication middleware
  io.use((socket, next) => {
    const token = socket.request.cookies?.accessToken;
    if (!token) {
      return next(new Error("Authentication error"));
    }
    try {
      const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
      socket.user = decoded;
      next();
    } catch (err) {
      next(new Error("Authentication error"));
    }
  });

  io.on("connection", async (socket) => {
    console.log(`User connected: ${socket.user.id}`);

    // Join user-specific room for direct notifications
    socket.join(`user:${socket.user.id}`);

    // Auto-join user to their conversation rooms
    try {
      const result = await pool.query(
        "SELECT conversation_id FROM participants WHERE user_id = $1",
        [socket.user.id]
      );
      result.rows.forEach((row) => {
        socket.join(`conversation:${row.conversation_id}`);
      });
      console.log(`User ${socket.user.id} joined ${result.rows.length} conversation rooms`);
    } catch (err) {
      console.error("Error joining rooms:", err);
    }

    socket.on("disconnect", () => {
      console.log(`User disconnected: ${socket.user.id}`);
    });
  });

  return io;
}

module.exports = { setupSocket };

Your src/server.js file has not changed in this step.

Your WebSocket connections are now secured behind the same JWT authentication as your REST API. Next up, you'll build conversation endpoints that let users create and list chat rooms.

Build Conversation Endpoints

Your WebSocket layer authenticates users and auto-joins them to rooms. Now you need a way for users to actually create conversations and see their existing ones.

In this step, you will build two REST endpoints that handle conversation creation and retrieval. The creation endpoint also broadcasts a real-time notification to all participants via Socket.IO.

In this step, get ready to:
  • Create a conversation controller with input validation and PostgreSQL transactions.
  • Add a list endpoint that returns conversations with participants and the last message.
  • Wire up routes and test both endpoints with curl.
Create the conversation controller

The createConversation function needs to do several things atomically: validate input, insert into two tables inside a transaction, then notify participants in real time.

  • Create a new file at src/controllers/conversationController.js and add the following code:
const { pool } = require("../config/db");

exports.createConversation = async (req, res) => {
  const { title, participantIds, isGroup } = req.body;
  const userId = req.user.id;

  // Validate that participantIds is a non-empty array
  if (!participantIds || !Array.isArray(participantIds) || participantIds.length === 0) {
    return res.status(400).json({
      success: false,
      error: { message: "participantIds must be a non-empty array" },
    });
  }

  // Ensure current user is included in participants
  const allParticipants = [...new Set([userId, ...participantIds])];

  // Direct conversations must have exactly 2 participants
  if (!isGroup && allParticipants.length !== 2) {
    return res.status(400).json({
      success: false,
      error: { message: "Direct conversations must have exactly 2 participants" },
    });
  }

  const client = await pool.connect();
  try {
    await client.query("BEGIN");

    // Insert the conversation record
    const conversationResult = await client.query(
      "INSERT INTO conversations (title, is_group) VALUES ($1, $2) RETURNING *",
      [isGroup ? title : null, isGroup || false]
    );
    const conversation = conversationResult.rows[0];

    // Add all participants to the conversation
    for (const participantId of allParticipants) {
      await client.query(
        "INSERT INTO participants (conversation_id, user_id) VALUES ($1, $2)",
        [conversation.id, participantId]
      );
    }

    await client.query("COMMIT");

    // Join connected participants to the new conversation room and notify them
    const io = req.app.get("io");
    for (const participantId of allParticipants) {
      const sockets = await io.in(`user:${participantId}`).fetchSockets();
      sockets.forEach((s) => s.join(`conversation:${conversation.id}`));
      io.to(`user:${participantId}`).emit("conversation:created", conversation);
    }

    res.status(201).json({ success: true, data: conversation });
  } catch (err) {
    await client.query("ROLLBACK");
    console.error("Error creating conversation:", err);
    res.status(500).json({
      success: false,
      error: { message: "Failed to create conversation" },
    });
  } finally {
    client.release();
  }
};

What does this code do?

This function handles the full lifecycle of creating a conversation:

  • Input validation ensures participantIds is a non-empty array and that direct (non-group) conversations have exactly 2 participants.
  • A PostgreSQL transaction wraps both the conversations INSERT and the participants INSERTs. If anything fails, ROLLBACK undoes everything so you never end up with orphaned rows.
  • After committing, io.in().fetchSockets() finds any connected sockets for each participant and joins them to the new conversation room. Then a conversation:created event notifies them instantly.
  • Save src/controllers/conversationController.js.

Why use a transaction here?

Without a transaction, if the participants INSERT fails after the conversations INSERT succeeds, you would have a conversation with no participants in the database. Wrapping both in BEGIN/COMMIT guarantees atomicity.

Add the list conversations function

Users also need to see all the conversations they belong to. This query uses PostgreSQL's json_agg and json_build_object to return participants and the last message as nested JSON directly from the database.

  • Add the following getUserConversations function below createConversation in src/controllers/conversationController.js:
exports.getUserConversations = async (req, res) => {
  const userId = req.user.id;

  try {
    const result = await pool.query(
      `SELECT c.*,
        (SELECT json_agg(json_build_object('id', u.id, 'username', u.username, 'avatar_url', u.avatar_url))
         FROM participants p
         JOIN users u ON p.user_id = u.id
         WHERE p.conversation_id = c.id) as participants,
        (SELECT json_build_object('content', m.content, 'sender_id', m.sender_id, 'created_at', m.created_at)
         FROM messages m
         WHERE m.conversation_id = c.id
         ORDER BY m.created_at DESC
         LIMIT 1) as last_message
       FROM conversations c
       JOIN participants p ON c.id = p.conversation_id
       WHERE p.user_id = $1
       ORDER BY c.updated_at DESC`,
      [userId]
    );

    res.json({ success: true, data: result.rows });
  } catch (err) {
    console.error("Error fetching conversations:", err);
    res.status(500).json({
      success: false,
      error: { message: "Failed to fetch conversations" },
    });
  }
};

What does this query do?

This single query returns everything a chat client needs to render a conversation list:

  • The first subquery uses json_agg(json_build_object(...)) to aggregate all participants for each conversation into a JSON array with their id, username, and avatar_url.
  • The second subquery fetches the most recent message (ORDER BY created_at DESC LIMIT 1) for displaying a preview in the conversation list.
  • Results are ordered by updated_at DESC so the most recently active conversation appears first.
  • Save src/controllers/conversationController.js.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/controllers/conversationController.js with both functions.

ⓧ I'd like to double check the full code

const { pool } = require("../config/db");

exports.createConversation = async (req, res) => {
  const { title, participantIds, isGroup } = req.body;
  const userId = req.user.id;

  if (!participantIds || !Array.isArray(participantIds) || participantIds.length === 0) {
    return res.status(400).json({
      success: false,
      error: { message: "participantIds must be a non-empty array" },
    });
  }

  const allParticipants = [...new Set([userId, ...participantIds])];

  if (!isGroup && allParticipants.length !== 2) {
    return res.status(400).json({
      success: false,
      error: { message: "Direct conversations must have exactly 2 participants" },
    });
  }

  const client = await pool.connect();
  try {
    await client.query("BEGIN");

    const conversationResult = await client.query(
      "INSERT INTO conversations (title, is_group) VALUES ($1, $2) RETURNING *",
      [isGroup ? title : null, isGroup || false]
    );
    const conversation = conversationResult.rows[0];

    for (const participantId of allParticipants) {
      await client.query(
        "INSERT INTO participants (conversation_id, user_id) VALUES ($1, $2)",
        [conversation.id, participantId]
      );
    }

    await client.query("COMMIT");

    const io = req.app.get("io");
    for (const participantId of allParticipants) {
      const sockets = await io.in(`user:${participantId}`).fetchSockets();
      sockets.forEach((s) => s.join(`conversation:${conversation.id}`));
      io.to(`user:${participantId}`).emit("conversation:created", conversation);
    }

    res.status(201).json({ success: true, data: conversation });
  } catch (err) {
    await client.query("ROLLBACK");
    console.error("Error creating conversation:", err);
    res.status(500).json({
      success: false,
      error: { message: "Failed to create conversation" },
    });
  } finally {
    client.release();
  }
};

exports.getUserConversations = async (req, res) => {
  const userId = req.user.id;

  try {
    const result = await pool.query(
      `SELECT c.*,
        (SELECT json_agg(json_build_object('id', u.id, 'username', u.username, 'avatar_url', u.avatar_url))
         FROM participants p
         JOIN users u ON p.user_id = u.id
         WHERE p.conversation_id = c.id) as participants,
        (SELECT json_build_object('content', m.content, 'sender_id', m.sender_id, 'created_at', m.created_at)
         FROM messages m
         WHERE m.conversation_id = c.id
         ORDER BY m.created_at DESC
         LIMIT 1) as last_message
       FROM conversations c
       JOIN participants p ON c.id = p.conversation_id
       WHERE p.user_id = $1
       ORDER BY c.updated_at DESC`,
      [userId]
    );

    res.json({ success: true, data: result.rows });
  } catch (err) {
    console.error("Error fetching conversations:", err);
    res.status(500).json({
      success: false,
      error: { message: "Failed to fetch conversations" },
    });
  }
};
Create the routes and register them in the server

With the controller ready, you need a router to map HTTP methods to your functions and protect them with your existing authentication middleware.

  • Create a new file at src/routes/conversations.js and add the following code:
const express = require("express");
const router = express.Router();
const { authenticate } = require("../middleware/auth");
const conversationController = require("../controllers/conversationController");

// POST / - Create a new conversation (requires auth)
router.post("/", authenticate, conversationController.createConversation);

// GET / - List the current user's conversations (requires auth)
router.get("/", authenticate, conversationController.getUserConversations);

module.exports = router;

What does this code do?

This router defines two endpoints, both guarded by your authenticate middleware from Project 1. POST / creates a new conversation and GET / lists existing ones. The middleware ensures only logged-in users can access these routes.

  • Save src/routes/conversations.js.

Now register this router in your server file so Express knows to handle requests at /api/conversations.

  • In src/server.js, find this line near the top of your imports:
const authRoutes = require("./routes/auth");
  • Add the following import directly below it:
const conversationRoutes = require("./routes/conversations");
  • Next, find this line further down in the file:
app.use("/api/auth", authRoutes);
  • Add the following route registration directly below it:
app.use("/api/conversations", conversationRoutes);
  • Save src/server.js.

Server crashing on save?

Check that your import path matches ./routes/conversations exactly. A typo in the path causes a MODULE_NOT_FOUND error on startup.

Also confirm you placed app.use("/api/conversations", conversationRoutes) after the app.use("/api/auth", authRoutes) line and before the Socket.IO setup block.

Help me debug my server crash.

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/routes/conversations.js and src/server.js.

ⓧ I'd like to double check the full code

const express = require("express");
const router = express.Router();
const { authenticate } = require("../middleware/auth");
const conversationController = require("../controllers/conversationController");

router.post("/", authenticate, conversationController.createConversation);
router.get("/", authenticate, conversationController.getUserConversations);

module.exports = router;
require("dotenv").config();
const express = require("express");
const { createServer } = require("node:http");
const { Server } = require("socket.io");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const authRoutes = require("./routes/auth");
const conversationRoutes = require("./routes/conversations");
const { pool } = require("./config/db");
const { setupSocket } = require("./socket");

const app = express();
const httpServer = createServer(app);
const PORT = process.env.PORT || 5000;

const corsOptions = {
  origin: process.env.CLIENT_URL || "http://localhost:3000",
  credentials: true,
};

app.use(cors(corsOptions));
app.use(express.json());
app.use(cookieParser());
app.use(express.static("public"));

app.get("/api/health", (req, res) => {
  res.json({ success: true, message: "Server is running" });
});

app.use("/api/auth", authRoutes);
app.use("/api/conversations", conversationRoutes);

const io = new Server(httpServer, {
  cors: corsOptions,
});

app.set("io", io);
setupSocket(io);

if (process.env.NODE_ENV !== "test") {
  httpServer.listen(PORT, async () => {
    try {
      const client = await pool.connect();
      console.log("Connected to PostgreSQL database");
      client.release();
    } catch (err) {
      console.error("Error connecting to PostgreSQL database", err);
    }
    console.log(`Server is running on port ${PORT}`);
  });
}

module.exports = { app, httpServer, io };
Test the endpoints with curl

Time to verify everything works end-to-end. You will register two users, log in, create a conversation, and then list conversations to confirm data is persisted correctly.

  • Make sure your server is running. If it is not, start it by running:
npm run dev
  • Register two test users by running these commands:
curl -X POST http://localhost:5000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@test.com", "username": "alice", "password": "Password123!"}'
curl -X POST http://localhost:5000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email": "bob@test.com", "username": "bob", "password": "Password123!"}'
  • Note the id field from the second registration response. You will need Bob's UUID in the next command.
  • Log in as Alice and save the cookie by running:
curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "alice@test.com", "password": "Password123!"}' \
  -c cookies.txt
  • Create a conversation with Bob by running this command (replace the UUID with Bob's actual id from the registration response):
curl -X POST http://localhost:5000/api/conversations \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"participantIds": ["[[BOB_USER_ID="Bob's UUID from registration"]"], "isGroup": false}'
  • You should see a JSON response with success: true and a data object containing the new conversation's id, is_group, and timestamps.
  • Verify the conversation appears in Alice's list by running:
curl http://localhost:5000/api/conversations \
  -b cookies.txt
  • You should see a response containing an array with one conversation object. It includes a participants array listing both Alice and Bob, and a last_message field (which is null since no messages have been sent yet).

Getting a 401 response?

Make sure you included -c cookies.txt on the login command to save the cookie file, and -b cookies.txt on subsequent commands to send it back. Without the cookie, your authenticate middleware rejects the request.

Help me troubleshoot my 401 response when creating conversations.

Your conversation endpoints are live and tested. Next up, you will build the message endpoints that persist messages to the database and broadcast them in real time to every participant in the conversation.

Send Messages with Real-Time Delivery

Your conversation system is live. Users can create conversations and see who they're chatting with. Now you need the most critical piece: the ability to send and receive messages.

In this step, you'll build the message endpoints that persist every message to PostgreSQL and simultaneously broadcast it to all connected participants via Socket.IO. This is the hybrid REST + WebSocket pattern that production chat apps like Slack and Discord use.

In this step, get ready to:
  • Build a message controller that persists messages and broadcasts them in real-time.
  • Implement cursor-based pagination for fetching message history.
  • Test the full send-and-receive flow with curl.
Create the message controller with sendMessage

The sendMessage function handles the dual responsibility of every message: persist it to the database for history, then push it instantly to every connected participant.

  • Create a new file at src/controllers/messageController.js and add the following code:
const { pool } = require("../config/db");

exports.sendMessage = async (req, res) => {
  const { conversationId } = req.params;
  const { content, messageType } = req.body;
  const senderId = req.user.id;

  // Validate message content
  if (!content || content.trim().length === 0) {
    return res.status(400).json({
      success: false,
      error: { message: "Message content is required" },
    });
  }

  try {
    // Verify user is a participant in this conversation
    const participantCheck = await pool.query(
      "SELECT id FROM participants WHERE conversation_id = $1 AND user_id = $2",
      [conversationId, senderId]
    );

    if (participantCheck.rows.length === 0) {
      return res.status(403).json({
        success: false,
        error: { message: "You are not a participant in this conversation" },
      });
    }

    // Insert message into database
    const result = await pool.query(
      `INSERT INTO messages (conversation_id, sender_id, content, message_type)
       VALUES ($1, $2, $3, $4) RETURNING *`,
      [conversationId, senderId, content.trim(), messageType || "text"]
    );
    const message = result.rows[0];

    // Update conversation timestamp so it sorts to the top
    await pool.query(
      "UPDATE conversations SET updated_at = NOW() WHERE id = $1",
      [conversationId]
    );

    // Fetch sender info for the broadcast payload
    const senderResult = await pool.query(
      "SELECT id, username, avatar_url FROM users WHERE id = $1",
      [senderId]
    );
    const sender = senderResult.rows[0];

    // Broadcast to all connected participants in this conversation room
    const io = req.app.get("io");
    io.to(`conversation:${conversationId}`).emit("message:new", {
      ...message,
      sender,
    });

    res.status(201).json({ success: true, data: message });
  } catch (err) {
    console.error("Error sending message:", err);
    res.status(500).json({
      success: false,
      error: { message: "Failed to send message" },
    });
  }
};

What does this code do?

The sendMessage function orchestrates four actions in sequence:

  • Verifies the sender is actually a participant in the conversation (returns 403 if not).
  • Inserts the message into the messages table and updates the conversation's updated_at so it sorts to the top of the list.
  • Fetches the sender's profile info so the broadcast payload includes the username.
  • Broadcasts a message:new event to the conversation room, reaching all connected participants instantly.

Why persist AND broadcast?

If you only broadcast via WebSocket, any participant who reconnects later would never see the message. If you only persist to the database, participants would need to poll for new messages.

The hybrid approach guarantees delivery. The database is the source of truth. The WebSocket broadcast is the instant-delivery optimization on top.

  • Save src/controllers/messageController.js.

You won't see a change yet. The next chunk adds the GET endpoint and wires up the routes so you can test the full flow.

Add message fetching and register routes

Clients need to load message history when they open a conversation. You'll use cursor-based pagination so clients can load older messages on demand without skipping or duplicating rows.

  • In src/controllers/messageController.js, add the getMessages function below your existing sendMessage export:
exports.getMessages = async (req, res) => {
  const { conversationId } = req.params;
  const userId = req.user.id;
  const { limit = 50, before } = req.query;

  try {
    // Verify user is a participant
    const participantCheck = await pool.query(
      "SELECT id FROM participants WHERE conversation_id = $1 AND user_id = $2",
      [conversationId, userId]
    );

    if (participantCheck.rows.length === 0) {
      return res.status(403).json({
        success: false,
        error: { message: "You are not a participant in this conversation" },
      });
    }

    // Build query with optional cursor-based pagination
    let query = `
      SELECT m.*, json_build_object('id', u.id, 'username', u.username, 'avatar_url', u.avatar_url) as sender
      FROM messages m
      JOIN users u ON m.sender_id = u.id
      WHERE m.conversation_id = $1`;
    const params = [conversationId];

    if (before) {
      query += ` AND m.created_at < $${params.length + 1}`;
      params.push(before);
    }

    query += ` ORDER BY m.created_at DESC LIMIT $${params.length + 1}`;
    params.push(parseInt(limit));

    const result = await pool.query(query, params);

    res.json({ success: true, data: result.rows });
  } catch (err) {
    console.error("Error fetching messages:", err);
    res.status(500).json({
      success: false,
      error: { message: "Failed to fetch messages" },
    });
  }
};

What is cursor-based pagination?

Instead of page numbers (which break when new messages arrive), cursor-based pagination uses a timestamp marker. The client sends before=2025-01-15T10:30:00Z and gets the next batch of messages older than that timestamp.

This ensures consistent results even when new messages are being inserted in real-time. The default limit of 50 keeps responses fast while giving enough context.

  • Save src/controllers/messageController.js.

Now you need a routes file to expose these functions as HTTP endpoints.

  • Create a new file at src/routes/messages.js and add the following code:
const express = require("express");
const router = express.Router();
const { authenticate } = require("../middleware/auth");
const messageController = require("../controllers/messageController");

// POST sends a message, GET fetches message history
router.post("/:conversationId", authenticate, messageController.sendMessage);
router.get("/:conversationId", authenticate, messageController.getMessages);

module.exports = router;

What does this code do?

Both routes use the :conversationId URL parameter so the controller knows which conversation to target. The authenticate middleware ensures only logged-in users can send or read messages.

  • Save src/routes/messages.js.
  • In src/server.js, find this line:
const conversationRoutes = require("./routes/conversations");
  • Add the message routes import directly below it:
const messageRoutes = require("./routes/messages");
  • Now find this line further down in src/server.js:
app.use("/api/conversations", conversationRoutes);
  • Add the message routes registration directly below it:
app.use("/api/messages", messageRoutes);
  • Save src/server.js.
  • Restart your server. You should see the health check still responds at http://localhost:5000/api/health without errors.

Server won't start?

  • Check that your import path in server.js matches the file location exactly: ./routes/messages.
  • Verify src/routes/messages.js exports module.exports = router at the bottom.
  • Make sure src/controllers/messageController.js has both exports.sendMessage and exports.getMessages defined.

Help me debug my server startup error.

Test the full messaging flow

Time to verify that messages persist to the database and the Socket.IO event fires correctly. You'll use curl to send a message and then fetch it back.

  • Log in as User A (one of the users you created in the previous step) by running:
curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "usera@test.com", "password": "Password123!"}' \
  -c cookies-a.txt
  • Send a message to the conversation you created earlier (replace the conversation ID with the one from your earlier test):
curl -X POST http://localhost:5000/api/messages/[[CONVERSATION_ID="your conversation UUID from the previous step"]] \
  -H "Content-Type: application/json" \
  -b cookies-a.txt \
  -d '{"content": "Hello from the messaging API!"}'
  • You should see a 201 response with the message data including conversation_id, sender_id, and content.
  • Now log in as User B and fetch messages from the same conversation:
curl -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "userb@test.com", "password": "Password123!"}' \
  -c cookies-b.txt
curl http://localhost:5000/api/messages/[[CONVERSATION_ID="your conversation UUID from the previous step"]] \
  -b cookies-b.txt
  • You should see the message from User A in the response, including the sender object with their username.
  • Check your server console. You should see that the message:new event was emitted when User A sent the message.

How do I know the real-time broadcast worked?

Right now you're testing with curl, which is a REST client. It can't receive WebSocket events. The fact that your server logs show the emit happened confirms the broadcast fired.

In Step 5, you'll write automated tests with socket.io-client that programmatically connect a WebSocket and assert the event arrives. That gives you full proof.

Getting a 403 forbidden response?

  • Make sure the user you're logged in as is a participant in the conversation you're targeting.
  • Double-check you're using the correct conversation UUID in the URL.
  • Verify your cookie file is being sent correctly with the -b flag.

Help me debug my 403 error when sending messages.

✔️ Awesome, I've got everything!

Great. Make sure you've saved all three files: src/controllers/messageController.js, src/routes/messages.js, and src/server.js.

ⓧ I'd like to double check the full code

Here is the complete src/controllers/messageController.js:

const { pool } = require("../config/db");

exports.sendMessage = async (req, res) => {
  const { conversationId } = req.params;
  const { content, messageType } = req.body;
  const senderId = req.user.id;

  if (!content || content.trim().length === 0) {
    return res.status(400).json({
      success: false,
      error: { message: "Message content is required" },
    });
  }

  try {
    const participantCheck = await pool.query(
      "SELECT id FROM participants WHERE conversation_id = $1 AND user_id = $2",
      [conversationId, senderId]
    );

    if (participantCheck.rows.length === 0) {
      return res.status(403).json({
        success: false,
        error: { message: "You are not a participant in this conversation" },
      });
    }

    const result = await pool.query(
      `INSERT INTO messages (conversation_id, sender_id, content, message_type)
       VALUES ($1, $2, $3, $4) RETURNING *`,
      [conversationId, senderId, content.trim(), messageType || "text"]
    );
    const message = result.rows[0];

    await pool.query(
      "UPDATE conversations SET updated_at = NOW() WHERE id = $1",
      [conversationId]
    );

    const senderResult = await pool.query(
      "SELECT id, username, avatar_url FROM users WHERE id = $1",
      [senderId]
    );
    const sender = senderResult.rows[0];

    const io = req.app.get("io");
    io.to(`conversation:${conversationId}`).emit("message:new", {
      ...message,
      sender,
    });

    res.status(201).json({ success: true, data: message });
  } catch (err) {
    console.error("Error sending message:", err);
    res.status(500).json({
      success: false,
      error: { message: "Failed to send message" },
    });
  }
};

exports.getMessages = async (req, res) => {
  const { conversationId } = req.params;
  const userId = req.user.id;
  const { limit = 50, before } = req.query;

  try {
    const participantCheck = await pool.query(
      "SELECT id FROM participants WHERE conversation_id = $1 AND user_id = $2",
      [conversationId, userId]
    );

    if (participantCheck.rows.length === 0) {
      return res.status(403).json({
        success: false,
        error: { message: "You are not a participant in this conversation" },
      });
    }

    let query = `
      SELECT m.*, json_build_object('id', u.id, 'username', u.username, 'avatar_url', u.avatar_url) as sender
      FROM messages m
      JOIN users u ON m.sender_id = u.id
      WHERE m.conversation_id = $1`;
    const params = [conversationId];

    if (before) {
      query += ` AND m.created_at < $${params.length + 1}`;
      params.push(before);
    }

    query += ` ORDER BY m.created_at DESC LIMIT $${params.length + 1}`;
    params.push(parseInt(limit));

    const result = await pool.query(query, params);

    res.json({ success: true, data: result.rows });
  } catch (err) {
    console.error("Error fetching messages:", err);
    res.status(500).json({
      success: false,
      error: { message: "Failed to fetch messages" },
    });
  }
};

Here is the complete src/routes/messages.js:

const express = require("express");
const router = express.Router();
const { authenticate } = require("../middleware/auth");
const messageController = require("../controllers/messageController");

router.post("/:conversationId", authenticate, messageController.sendMessage);
router.get("/:conversationId", authenticate, messageController.getMessages);

module.exports = router;

Here is the complete src/server.js:

require("dotenv").config();
const express = require("express");
const { createServer } = require("node:http");
const { Server } = require("socket.io");
const cors = require("cors");
const cookieParser = require("cookie-parser");
const authRoutes = require("./routes/auth");
const conversationRoutes = require("./routes/conversations");
const messageRoutes = require("./routes/messages");
const { pool } = require("./config/db");
const { setupSocket } = require("./socket");

const app = express();
const httpServer = createServer(app);
const PORT = process.env.PORT || 5000;

const corsOptions = {
  origin: process.env.CLIENT_URL || "http://localhost:3000",
  credentials: true,
};

app.use(cors(corsOptions));
app.use(express.json());
app.use(cookieParser());
app.use(express.static("public"));

app.get("/api/health", (req, res) => {
  res.json({ success: true, message: "Server is running" });
});

app.use("/api/auth", authRoutes);
app.use("/api/conversations", conversationRoutes);
app.use("/api/messages", messageRoutes);

const io = new Server(httpServer, {
  cors: corsOptions,
});

app.set("io", io);
setupSocket(io);

if (process.env.NODE_ENV !== "test") {
  httpServer.listen(PORT, async () => {
    try {
      const client = await pool.connect();
      console.log("Connected to PostgreSQL database");
      client.release();
    } catch (err) {
      console.error("Error connecting to PostgreSQL database", err);
    }
    console.log(`Server is running on port ${PORT}`);
  });
}

module.exports = { app, httpServer, io };

Your messaging layer is fully functional. Messages persist to the database, conversations update their timestamps, and Socket.IO pushes events to the right rooms. Next up, you'll write automated integration tests that prove all of this works end-to-end.

Write Automated Integration Tests

You have a working messaging backend that persists data and broadcasts events in real time. But how do you prove it works reliably without manually testing every endpoint and Socket.IO event after each change?

In this step, you'll build an automated integration test suite that verifies your REST endpoints and real-time WebSocket events against your actual PostgreSQL database. These are true integration tests, not mocked unit tests.

In this step, get ready to:
  • Install Jest, Supertest, and socket.io-client as testing dependencies.
  • Write integration tests for conversation creation and listing.
  • Write integration tests proving Socket.IO events fire correctly when messages are sent.
Install testing dependencies and configure Jest

Jest is a JavaScript testing framework that provides test runners, assertions, and lifecycle hooks. Supertest lets you make HTTP requests to your Express app without starting a real server on a fixed port.

  • Install the three testing packages as dev dependencies by running this command in your project root:
npm install --save-dev jest@30.4.2 supertest@7.2.2 socket.io-client@4.8.3

Why these three packages?

Jest 30.4.2 runs your test suites and provides assertion functions like expect(). Supertest 7.2.2 sends HTTP requests to your Express app programmatically.

The socket.io-client 4.8.3 package lets your tests connect as a WebSocket client, so you can assert on real-time events your server emits.

  • Confirm the packages were added by checking your package.json devDependencies section. You should see all three listed with their version numbers.

Install failing with network errors?

Make sure you are connected to the internet and running the command from your project root (the folder containing package.json). If you see permission errors on macOS, try prefixing with sudo.

Help me debug my npm install for testing packages.

Next, you need to tell Jest where to find test files and how to run them.

  • Create a jest.config.js file at your project root (same level as package.json) and add the following configuration:
module.exports = {
  testEnvironment: "node",
  testMatch: ["**/__tests__/**/*.test.js"],
  verbose: true,
};

What does this config do?

  • testEnvironment: "node" tells Jest to use a Node.js environment instead of a browser-like DOM environment.
  • testMatch tells Jest to look for test files inside the __tests__ directory that end with .test.js.
  • verbose: true prints each individual test name as it runs, making it easier to spot which test failed.
  • Save jest.config.js.
  • Open package.json and find the "scripts" section.
  • Add these two test scripts:
"test": "jest --runInBand --forceExit",
"test:watch": "jest --runInBand --watch"

Why these flags?

The --runInBand flag runs tests sequentially instead of in parallel. This is important because your tests share one PostgreSQL database, and parallel execution would cause data races.

The --forceExit flag forces Jest to exit after tests complete. Without it, Socket.IO connections can keep the Node.js event loop open indefinitely.

  • Save package.json.

Now you need a shared setup file that starts your server on a random port before tests run and tears everything down after.

  • Create a __tests__ directory at your project root, then create a __tests__/setup.js file with the following code:
const { app, httpServer, io } = require("../src/server");
const { pool } = require("../src/config/db");
const request = require("supertest");

let serverUrl;
let server;

beforeAll(async () => {
  await new Promise((resolve) => {
    server = httpServer.listen(0, () => {
      const port = server.address().port;
      serverUrl = `http://localhost:${port}`;
      resolve();
    });
  });
});

afterAll(async () => {
  io.close();
  server.close();
  await pool.end();
});

async function createTestUser(email, username, password) {
  const res = await request(app)
    .post("/api/auth/register")
    .send({ email, username, password });
  return res.body;
}

async function loginUser(email, password) {
  const res = await request(app)
    .post("/api/auth/login")
    .send({ email, password });
  const cookies = res.headers["set-cookie"];
  return { body: res.body, cookies };
}

function getAuthCookie(cookies) {
  if (!cookies) return "";
  const accessCookie = cookies.find((c) => c.startsWith("accessToken="));
  return accessCookie || "";
}

module.exports = { app, serverUrl, createTestUser, loginUser, getAuthCookie, io };

What does this setup file do?

  • beforeAll starts your HTTP server on port 0 (the OS picks a random available port). This avoids conflicts with your development server.
  • afterAll shuts down Socket.IO, the HTTP server, and the database pool so Jest can exit cleanly.
  • createTestUser and loginUser are helpers that call your existing auth endpoints to create authenticated test sessions.
  • getAuthCookie extracts the accessToken cookie from the login response so tests can pass it to authenticated requests.
  • Save __tests__/setup.js.

Why port 0?

Passing 0 to httpServer.listen() tells the operating system to assign any available port. This means you can run tests while your dev server is still active on port 5000 without conflicts.

Seeing MODULE_NOT_FOUND errors?

Make sure your __tests__ folder is at the project root (next to src/ and package.json), not inside src/. The require paths in setup.js use "../src/server" which assumes __tests__/ is one level up from src/.

Help me fix MODULE_NOT_FOUND errors in my Jest setup.

Write conversation endpoint tests

Your first test file will verify that conversation creation and listing work correctly, including authentication enforcement.

  • Create __tests__/conversations.test.js with the following test suite:
const request = require("supertest");
const { app, createTestUser, loginUser, getAuthCookie } = require("./setup");

describe("Conversation Endpoints", () => {
  let userACookies, userBCookies;
  let userAId, userBId;

  beforeAll(async () => {
    await createTestUser("convo-a@test.com", "convoUserA", "Password123!");
    await createTestUser("convo-b@test.com", "convoUserB", "Password123!");

    const loginA = await loginUser("convo-a@test.com", "Password123!");
    userACookies = loginA.cookies;
    userAId = loginA.body.data?.user?.id || loginA.body.user?.id;

    const loginB = await loginUser("convo-b@test.com", "Password123!");
    userBCookies = loginB.cookies;
    userBId = loginB.body.data?.user?.id || loginB.body.user?.id;
  });

  describe("POST /api/conversations", () => {
    test("creates a conversation with valid participants", async () => {
      const res = await request(app)
        .post("/api/conversations")
        .set("Cookie", getAuthCookie(userACookies))
        .send({ participantIds: [userBId], isGroup: false });

      expect(res.status).toBe(201);
      expect(res.body.success).toBe(true);
      expect(res.body.data).toHaveProperty("id");
      expect(res.body.data.is_group).toBe(false);
    });

    test("returns 401 without auth", async () => {
      const res = await request(app)
        .post("/api/conversations")
        .send({ participantIds: [userBId], isGroup: false });

      expect(res.status).toBe(401);
    });

    test("returns 400 with invalid participantIds", async () => {
      const res = await request(app)
        .post("/api/conversations")
        .set("Cookie", getAuthCookie(userACookies))
        .send({ participantIds: [], isGroup: false });

      expect(res.status).toBe(400);
    });
  });

  describe("GET /api/conversations", () => {
    test("returns user conversations with participants", async () => {
      const res = await request(app)
        .get("/api/conversations")
        .set("Cookie", getAuthCookie(userACookies));

      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);
      expect(Array.isArray(res.body.data)).toBe(true);
      expect(res.body.data.length).toBeGreaterThan(0);
      expect(res.body.data[0]).toHaveProperty("participants");
    });
  });
});

What does this test file cover?

  • beforeAll registers two test users and logs them in, capturing their auth cookies and user IDs for use in every test.
  • The POST tests verify that creating a conversation returns 201 with the correct structure, unauthenticated requests get 401, and empty participant arrays get 400.
  • The GET test confirms that listing conversations returns the correct data including nested participant info.
  • Save __tests__/conversations.test.js.

Confused by the cookie pattern?

The .set("Cookie", getAuthCookie(userACookies)) line attaches the JWT cookie to each request, mimicking what a browser does automatically. Without it, Supertest requests are unauthenticated.

Help me understand how auth cookies work in Supertest.

Write message endpoint and Socket.IO tests

The message tests go further than REST assertions. They also connect a programmatic Socket.IO client and verify that real-time events fire when messages are sent.

  • Create __tests__/messages.test.js with the following test suite:
const request = require("supertest");
const { io: ioClient } = require("socket.io-client");
const { app, serverUrl, createTestUser, loginUser, getAuthCookie, io } = require("./setup");

describe("Message Endpoints", () => {
  let userACookies, userBCookies;
  let userAId, userBId;
  let conversationId;

  beforeAll(async () => {
    await createTestUser("msg-a@test.com", "msgUserA", "Password123!");
    await createTestUser("msg-b@test.com", "msgUserB", "Password123!");

    const loginA = await loginUser("msg-a@test.com", "Password123!");
    userACookies = loginA.cookies;
    userAId = loginA.body.data?.user?.id || loginA.body.user?.id;

    const loginB = await loginUser("msg-b@test.com", "Password123!");
    userBCookies = loginB.cookies;
    userBId = loginB.body.data?.user?.id || loginB.body.user?.id;

    // Create a conversation between them
    const convoRes = await request(app)
      .post("/api/conversations")
      .set("Cookie", getAuthCookie(userACookies))
      .send({ participantIds: [userBId], isGroup: false });
    conversationId = convoRes.body.data.id;
  });

  describe("POST /api/messages/:conversationId", () => {
    test("sends a message and returns 201", async () => {
      const res = await request(app)
        .post(`/api/messages/${conversationId}`)
        .set("Cookie", getAuthCookie(userACookies))
        .send({ content: "Hello from tests!" });

      expect(res.status).toBe(201);
      expect(res.body.success).toBe(true);
      expect(res.body.data.content).toBe("Hello from tests!");
      expect(res.body.data.sender_id).toBe(userAId);
    });

    test("returns 403 for non-participants", async () => {
      // Create a third user who is not in the conversation
      await createTestUser("msg-c@test.com", "msgUserC", "Password123!");
      const loginC = await loginUser("msg-c@test.com", "Password123!");

      const res = await request(app)
        .post(`/api/messages/${conversationId}`)
        .set("Cookie", getAuthCookie(loginC.cookies))
        .send({ content: "I should not be able to send this" });

      expect(res.status).toBe(403);
    });
  });

  describe("GET /api/messages/:conversationId", () => {
    test("returns messages in reverse chronological order", async () => {
      const res = await request(app)
        .get(`/api/messages/${conversationId}`)
        .set("Cookie", getAuthCookie(userACookies));

      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);
      expect(Array.isArray(res.body.data)).toBe(true);
      expect(res.body.data[0]).toHaveProperty("sender");
      expect(res.body.data[0].sender).toHaveProperty("username");
    });
  });

  describe("Socket.IO real-time events", () => {
    test("connected user receives message:new event", async () => {
      const cookie = getAuthCookie(userBCookies);

      const clientSocket = ioClient(serverUrl, {
        extraHeaders: { cookie },
        transports: ["polling"],
      });

      await new Promise((resolve) => clientSocket.on("connect", resolve));

      // Wait briefly for room auto-join to complete
      await new Promise((resolve) => setTimeout(resolve, 200));

      const messagePromise = new Promise((resolve) => {
        clientSocket.on("message:new", (msg) => {
          resolve(msg);
        });
      });

      // Send a message as User A
      await request(app)
        .post(`/api/messages/${conversationId}`)
        .set("Cookie", getAuthCookie(userACookies))
        .send({ content: "Real-time test message" });

      const receivedMsg = await messagePromise;
      expect(receivedMsg.content).toBe("Real-time test message");
      expect(receivedMsg.sender.username).toBe("msgUserA");

      clientSocket.disconnect();
    });
  });
});

What does the Socket.IO test do?

The final test connects a socket.io-client as User B using the extraHeaders option to pass the auth cookie. It uses transports: ["polling"] because HTTP long-polling sends headers on every request, making cookie auth work reliably in tests.

After connecting, it listens for the message:new event, then triggers an HTTP POST as User A. The test passes only if User B's socket receives the broadcast with the correct message content and sender username.

  • Save __tests__/messages.test.js.
  • Make sure your Docker container with PostgreSQL is running (from Project 1).
  • Run your test suite by executing this command:
npm test
  • You should see all tests passing with green checkmarks. Jest displays each test name thanks to the verbose: true setting.

Tests failing or timing out?

  • If you see connection errors, make sure your Docker container with PostgreSQL is running and your .env file has the correct database credentials.
  • If the Socket.IO test times out, check that your server.js exports both httpServer and io. The test needs both to start the server and connect a client.
  • If you see duplicate key errors, your database may still have test users from a previous run. This is expected with integration tests against a shared database.

Help me debug my failing Jest integration tests.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your test files.

ⓧ I'd like to double check the full code

Here are the complete files for this step.

module.exports = {
  testEnvironment: "node",
  testMatch: ["**/__tests__/**/*.test.js"],
  verbose: true,
};
const { app, httpServer, io } = require("../src/server");
const { pool } = require("../src/config/db");
const request = require("supertest");

let serverUrl;
let server;

beforeAll(async () => {
  await new Promise((resolve) => {
    server = httpServer.listen(0, () => {
      const port = server.address().port;
      serverUrl = `http://localhost:${port}`;
      resolve();
    });
  });
});

afterAll(async () => {
  io.close();
  server.close();
  await pool.end();
});

async function createTestUser(email, username, password) {
  const res = await request(app)
    .post("/api/auth/register")
    .send({ email, username, password });
  return res.body;
}

async function loginUser(email, password) {
  const res = await request(app)
    .post("/api/auth/login")
    .send({ email, password });
  const cookies = res.headers["set-cookie"];
  return { body: res.body, cookies };
}

function getAuthCookie(cookies) {
  if (!cookies) return "";
  const accessCookie = cookies.find((c) => c.startsWith("accessToken="));
  return accessCookie || "";
}

module.exports = { app, serverUrl, createTestUser, loginUser, getAuthCookie, io };
const request = require("supertest");
const { app, createTestUser, loginUser, getAuthCookie } = require("./setup");

describe("Conversation Endpoints", () => {
  let userACookies, userBCookies;
  let userAId, userBId;

  beforeAll(async () => {
    await createTestUser("convo-a@test.com", "convoUserA", "Password123!");
    await createTestUser("convo-b@test.com", "convoUserB", "Password123!");

    const loginA = await loginUser("convo-a@test.com", "Password123!");
    userACookies = loginA.cookies;
    userAId = loginA.body.data?.user?.id || loginA.body.user?.id;

    const loginB = await loginUser("convo-b@test.com", "Password123!");
    userBCookies = loginB.cookies;
    userBId = loginB.body.data?.user?.id || loginB.body.user?.id;
  });

  describe("POST /api/conversations", () => {
    test("creates a conversation with valid participants", async () => {
      const res = await request(app)
        .post("/api/conversations")
        .set("Cookie", getAuthCookie(userACookies))
        .send({ participantIds: [userBId], isGroup: false });

      expect(res.status).toBe(201);
      expect(res.body.success).toBe(true);
      expect(res.body.data).toHaveProperty("id");
      expect(res.body.data.is_group).toBe(false);
    });

    test("returns 401 without auth", async () => {
      const res = await request(app)
        .post("/api/conversations")
        .send({ participantIds: [userBId], isGroup: false });

      expect(res.status).toBe(401);
    });

    test("returns 400 with invalid participantIds", async () => {
      const res = await request(app)
        .post("/api/conversations")
        .set("Cookie", getAuthCookie(userACookies))
        .send({ participantIds: [], isGroup: false });

      expect(res.status).toBe(400);
    });
  });

  describe("GET /api/conversations", () => {
    test("returns user conversations with participants", async () => {
      const res = await request(app)
        .get("/api/conversations")
        .set("Cookie", getAuthCookie(userACookies));

      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);
      expect(Array.isArray(res.body.data)).toBe(true);
      expect(res.body.data.length).toBeGreaterThan(0);
      expect(res.body.data[0]).toHaveProperty("participants");
    });
  });
});
const request = require("supertest");
const { io: ioClient } = require("socket.io-client");
const { app, serverUrl, createTestUser, loginUser, getAuthCookie, io } = require("./setup");

describe("Message Endpoints", () => {
  let userACookies, userBCookies;
  let userAId, userBId;
  let conversationId;

  beforeAll(async () => {
    await createTestUser("msg-a@test.com", "msgUserA", "Password123!");
    await createTestUser("msg-b@test.com", "msgUserB", "Password123!");

    const loginA = await loginUser("msg-a@test.com", "Password123!");
    userACookies = loginA.cookies;
    userAId = loginA.body.data?.user?.id || loginA.body.user?.id;

    const loginB = await loginUser("msg-b@test.com", "Password123!");
    userBCookies = loginB.cookies;
    userBId = loginB.body.data?.user?.id || loginB.body.user?.id;

    // Create a conversation between them
    const convoRes = await request(app)
      .post("/api/conversations")
      .set("Cookie", getAuthCookie(userACookies))
      .send({ participantIds: [userBId], isGroup: false });
    conversationId = convoRes.body.data.id;
  });

  describe("POST /api/messages/:conversationId", () => {
    test("sends a message and returns 201", async () => {
      const res = await request(app)
        .post(`/api/messages/${conversationId}`)
        .set("Cookie", getAuthCookie(userACookies))
        .send({ content: "Hello from tests!" });

      expect(res.status).toBe(201);
      expect(res.body.success).toBe(true);
      expect(res.body.data.content).toBe("Hello from tests!");
      expect(res.body.data.sender_id).toBe(userAId);
    });

    test("returns 403 for non-participants", async () => {
      // Create a third user who is not in the conversation
      await createTestUser("msg-c@test.com", "msgUserC", "Password123!");
      const loginC = await loginUser("msg-c@test.com", "Password123!");

      const res = await request(app)
        .post(`/api/messages/${conversationId}`)
        .set("Cookie", getAuthCookie(loginC.cookies))
        .send({ content: "I should not be able to send this" });

      expect(res.status).toBe(403);
    });
  });

  describe("GET /api/messages/:conversationId", () => {
    test("returns messages in reverse chronological order", async () => {
      const res = await request(app)
        .get(`/api/messages/${conversationId}`)
        .set("Cookie", getAuthCookie(userACookies));

      expect(res.status).toBe(200);
      expect(res.body.success).toBe(true);
      expect(Array.isArray(res.body.data)).toBe(true);
      expect(res.body.data[0]).toHaveProperty("sender");
      expect(res.body.data[0].sender).toHaveProperty("username");
    });
  });

  describe("Socket.IO real-time events", () => {
    test("connected user receives message:new event", async () => {
      const cookie = getAuthCookie(userBCookies);

      const clientSocket = ioClient(serverUrl, {
        extraHeaders: { cookie },
        transports: ["polling"],
      });

      await new Promise((resolve) => clientSocket.on("connect", resolve));

      // Wait briefly for room auto-join to complete
      await new Promise((resolve) => setTimeout(resolve, 200));

      const messagePromise = new Promise((resolve) => {
        clientSocket.on("message:new", (msg) => {
          resolve(msg);
        });
      });

      // Send a message as User A
      await request(app)
        .post(`/api/messages/${conversationId}`)
        .set("Cookie", getAuthCookie(userACookies))
        .send({ content: "Real-time test message" });

      const receivedMsg = await messagePromise;
      expect(receivedMsg.content).toBe("Real-time test message");
      expect(receivedMsg.sender.username).toBe("msgUserA");

      clientSocket.disconnect();
    });
  });
});

You now have a production-grade test suite that proves your messaging backend works end-to-end. These tests verify authentication, access control, data persistence, and real-time event delivery. Next up, you'll push further with a Secret Mission to add read receipts.

Secret mission

Read Receipts

Your messaging backend delivers messages in real-time, but recipients have no way to signal they have actually seen a message. Add read receipt endpoints that persist to the database, broadcast instantly via Socket.IO, and handle duplicate reads gracefully. Then prove it all works with automated tests.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker, so there are no ongoing cloud costs. Decide whether to keep your resources running for Project 3, pause them to come back later, or delete them entirely.

Resources you used:

  • PostgreSQL database (via Docker container from Project 1).
  • Project source files (src/, __tests__/, jest.config.js, package.json).

Keep everything running

No action needed. Choose this if you plan to continue into Project 3, which builds on everything you created here.

  • Leave your Docker container running so PostgreSQL stays available.
  • Your project files remain on disk and ready for the next project.
  • Cost: $0. Everything runs locally on your machine.

Pause - I'll come back to this later

Stop the Docker container to free up memory while preserving your database data for later.

  • Stop the container by running this command from your project directory:
docker compose stop
  • Your database data is preserved. When you want to resume, run docker compose start to bring it back.
  • Your project source files stay on disk untouched.

Delete - I don't want to use this again

Remove the Docker container, volumes, and all stored data. This is irreversible.

  • Remove the Docker container and all database data by running:
docker compose down -v
  • Delete your project source files by running:
cd ~/Desktop
rm -rf messaging-backend

On Windows?

Use this command instead to remove the project folder:

cd ~\Desktop
rmdir /s /q messaging-backend

Nice Work!

Nice Work!

Nice work! You just built a real-time messaging backend that authenticates WebSocket connections, persists chat messages to PostgreSQL, and broadcasts them instantly to conversation participants.

You've learned how to:

  • Integrate Socket.IO with an existing Express + JWT auth backend, authenticating WebSocket connections via httpOnly cookies and auto-joining users to conversation rooms.
  • Build a hybrid REST + WebSocket architecture where messages persist to PostgreSQL and broadcast instantly to all connected participants via Socket.IO rooms.
  • Write production-grade integration tests with Jest, Supertest, and socket.io-client that prove REST endpoints, access control, and real-time Socket.IO events all work end-to-end.
  • Secret Mission: Added read receipts with database persistence, real-time broadcast to conversation rooms, and automated test coverage proving idempotent behavior.

Ready to quiz yourself?