DEV Community

Anastasiia Kim
Anastasiia Kim

Posted on

Build a Real-Time Chat App with WebSockets

In this article, I’d like to introduce you to an interesting and practical project that’s great for beginners: building a real-time chat application using WebSockets. This project is a good way to learn how the frontend, backend, and database work together in a web application.

WebSocket is a communications protocol that enables two-way interactive communication between a user's browser and a server. Unlike the traditional HTTP request-response model (where the client must make a request for the server to respond), WebSocket keeps the connection open. This makes it ideal for real-time applications like chat systems, multiplayer games, or live notifications.

Project Overview
Our WebSocket chat project will be divided into three main parts:

  • Frontend – the interface users interact with
  • Backend – the server-side logic and WebSocket management
  • Database – storage for messages and user data

Technologies used
Frontend: Svelte + Vue (you can use your preferred framework)
Backend: Node.js with Express and WebSocket
Database: PostgreSQL (running via Docker Compose)

Our project will have a directory like this:

your_project/
├── src/
│   ├── assets/
│   ├── components/
│   │   └── ChatWidget.vue
│   ├── backend/
│   │   ├── server.js
│   │   └── db.js
│   └── App.vue
Enter fullscreen mode Exit fullscreen mode

Dependecy setup

Before you begin, make sure you have Node.js with Express, Websocket and PostgreSQL. If not, install them using:

npm install express cors ws postgres psql
Enter fullscreen mode Exit fullscreen mode

You can check them in the file package.json.

"dependencies": {
    "psql": "^0.0.1",
    "vue": "^3.5.13",
    "ws": "^8.18.2"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.2.3",
    "@vue/tsconfig": "^0.7.0",
    "cors": "^2.8.5",
    "express": "^4.21.2",
    "postgres": "^3.4.5",
    "typescript": "~5.8.3",
    "vite": "^6.3.5",
    "vue-tsc": "^2.2.8"
  }
Enter fullscreen mode Exit fullscreen mode

The npm run dev command only starts the frontend. You’ll also need to run the backend and database separately.

In the file package.json add the next following in the scripts:

  "scripts": {
    "dev": "vite",
    "dev:server": "node src/backend/server.js", // add this line
    "build": "vue-tsc -b && vite build",
    "preview": "vite preview"
  },
Enter fullscreen mode Exit fullscreen mode

So now, you can run 3 part of the project in 3 different terminals:

  • npm run dev
  • npm run dev:server
  • docker compose up

Frontend

App.vue is the main component that handles data fetching and websocket connection.

Minimal implemented functionality:

  • Loads recent messages from the backend on page load
  • Opens a WebSocket connection to receive and send messages in real time
  • Scrolls to the bottom when new messages arrive
  • Displays messages with user avatars and timestamps
  • Supports multiple usernames

Let’s take a closer look at the script section of App.vue.

<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import ChatWidget from './components/ChatWidget.vue'

type Message = {
  id: number
  username: string
  datetime: string
  msgtext: string
}

const socket = ref<WebSocket | null>(null)
const messages = ref<Message[]>([])
const textMessage = ref('')
const username = ref('')
const chatContainer = ref<HTMLElement | null>(null)

// Scroll to the last message
function scrollToBottom() {
  if (chatContainer.value) {
    chatContainer.value.scrollTop = chatContainer.value.scrollHeight
  }
}

// Fetches previous messages from the backend and opens 
// connection to Websocket, handles incoming and outgoing 
// messages
onMounted(async() => {

  try {
    const res = await fetch('http://192.168.1.203:8080/messages')
    const lastMessages = await res.json()

    messages.value = lastMessages.map((m: any) => ({
      id: m.id,
      username: m.username,
      msgtext: m.msgtext,
      datetime: m.datetime
    }))

    nextTick(() => scrollToBottom())
  } catch (err) {
    console.error('failed to fetch messages: ', err)
  };

  socket.value = new WebSocket('wss://192.168.1.203:8080')

  socket.value.addEventListener('open', () => {
    console.log('WebSocket connection established')
  })

  socket.value.addEventListener('message', (event) => {
    try {
      const msg: Message = JSON.parse(event.data)
      messages.value.push(msg)

      nextTick(() => scrollToBottom())
    } catch (err) {
      console.error('Failed to parse message:', err)
    }
  })

  socket.value.addEventListener('close', () => {
    console.log('WebSocket disconnected')
  })
})

// optional selection of users
function selectUser(user: 'Mouse' | 'Bear' | 'unknown') {
  username.value = user
}

function sendMessage() {
  if (!socket.value || socket.value.readyState !== WebSocket.OPEN) return
  if (textMessage.value.trim() === '') return

  const newMessage: Message = {
    username: username.value,
    datetime: new Date().toISOString(),
    msgtext: textMessage.value.trim()
  }

  socket.value.send(JSON.stringify(newMessage))
  textMessage.value = ''
}

</script>
Enter fullscreen mode Exit fullscreen mode

You can design your page however you want, but the main thing is to pass data to the elements attached to it in the template:

<template>
  <div class="headline"><a>Mouse gossip</a></div>
  <div class="phrases"><a>Spill the tea</a></div>

  <div class="chatbox" ref="chatContainer">
    <div class="widgets">
      <ChatWidget 
        v-for="(msg) in messages"
        :key="msg.id" 
        :username="msg.username"
        :msgtext="msg.msgtext" 
        :datetime="msg.datetime" 
      />
    </div>
  </div>

  <div class="textbox">
    <button class="button-bear" @click="selectUser('Bear')">
        <img src="./assets/gossip-bear.png" alt="bear-img"/>
      </button>
      <button class="button-mouse" @click="selectUser('Mouse')">
        <img src="./assets/gossip-mouse.png" alt="mouse-img"/>
      </button>
    <textarea class="usernameInput" v-model="username" />
    <textarea class="textInput" v-model="textMessage" @keyup.enter="sendMessage" maxlength="200"/>
    <button @click="sendMessage">Send</button>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

ChatWidget.vue is a component which is responsible for rendering a widget with message, profile image, username and timestamp.

<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{
      username: string
      msgtext: string
      datetime: string
}>()

const { username, msgtext, datetime } = props

const formattedDatetime = computed(() => {
  return new Date(datetime).toLocaleString() 
})

const getAvatar = (name: string) => {
  if (name === 'Bear') return '/src/assets/gossip-bear.png'
  if (name === 'Mouse') return '/src/assets/gossip-mouse.png'
  return '/src/assets/gossip-hippo.png'
}

</script>
Enter fullscreen mode Exit fullscreen mode

You can style elements to your liking. Change optional names for users or change profile pictures.

Backend

The backend is responsible for:

  • receiving and storing messages
  • broadcasting messages to all connected clients
  • serving past messages in the chat
  • handling websocket connections

The following code will be in a file in the directory your_project/src/backend/server.js

In this file we will add the backend logic and connection to database.

import express from 'express';
import cors from 'cors';
import sql from './db.js';
import { performance } from 'perf_hooks'
import { WebSocketServer } from 'ws'
import http from 'http'

const app = express();
const router = express.Router();
const clients = new Set();
const server = http.createServer(app)
const wss = new WebSocketServer({ server })

app.use(cors());
app.use(express.json());

// connecting to the database
sql`SELECT 1`.then(() => {
  console.log('Connected to Postgres');
}).catch((err) => {
  console.error('Connection to Postgres failed:', err);
});

// middleware for request timing
router.use( async (req, res, next) => {
    const start = performance.now();
    console.log('Time start: ', start);
    next();
    const end = performance.now();
    console.log('Time end: ', end);
});

// check the server is working
router.get('/', async (req, res) => {
    res.json({message: 'Server is working'});
  });

// get recent messages 
router.get('/messages', async (req, res) => {
  try {
    const msg = await getLastMessages();
    res.json(msg);

  } catch (err) {
    console.error('failed to fetch messages:', err);
  }
});

// starting the server
app.use('/', router)

server.listen(8080, () => {
  console.log('Server running on http://192.168.1.203:8080/');
});

// websocket server
wss.on('connection', function connection(ws) {
  clients.add(ws);
  console.log('Client is connected');

  ws.on('message', async function incoming(data) {
    try {
      const msg = JSON.parse(data);
      console.log('received: %o', msg);

      await insertMessage({
        username: msg.username,
        msgtext: msg.msgtext,
        datetime: msg.datetime
      });

      for (const client of clients) {
        if (client.readyState === WebSocket.OPEN) {
          client.send(JSON.stringify(msg));
        }
      }
    } catch (err) {
      console.error('Error handling message:', err);
    }
  });

  ws.on('close', function () {
    clients.delete(ws);
    console.log('Client is disconnected');
  })
});

// adding messages into the database
async function insertMessage({username, msgtext, datetime}) {
  const msg = await sql`
    insert into messages 
      (username, msgtext, datetime)
    values
      (${username}, ${msgtext}, ${datetime})
    returning id, username, msgtext, datetime`
  return msg;
};

// fetch the last 30 messages
async function getLastMessages() {
  const msg = await sql`
  select id, username, msgtext, datetime
  from messages 
  order by datetime desc
  limit 30`

  return msg.reverse();
};
Enter fullscreen mode Exit fullscreen mode

Database

The database is where we store chat messages so they don't disappear when users refresh the page. For this project, we use PostgreSQL.

Instead of installing PostgreSQL manually, we’ll run it in a Docker container. This method is fast, isolated, and easy to configure, especially for beginners.

It’s a good practice to separate concerns during development. That’s why we’ll create a dedicated file for the database connection. In your_project/src/server/db.js, add the following:

import postgres from 'postgres'

const sql = postgres({
    host : '127.0.0.1',
    port : 5432,
    database: 'yourprojectdb',
    username: 'admin',
    password: 'strong',
});

export default sql
Enter fullscreen mode Exit fullscreen mode

Next, in the root your project, create a docker-compose.yml file:

version: '3.8'
services:
  postgres:
    image: postgres:latest
    restart: unless-stopped
    environment:
      - POSTGRES_USER=admin
      - POSTGRES_PASSWORD=strong
      - POSTGRES_DB=yourprojectdb
    ports:
      - "5432:5432"
    volumes:
      - ./pgdata:/var/lib/postgresql/data
Enter fullscreen mode Exit fullscreen mode

Make sure the postgres configurations in db.js file match those in the docker-compose.yml.
This setup will:

  • Start a PostgreSQL server using the latest official image
  • Expose it on port 5432
  • Set up a database called yourprojectdb with user and password
  • Persist the data to the local folder ./pgdata so data is not lost when the container stops

With Docker installed and running, launch the container from your project root:

docker compose up
Enter fullscreen mode Exit fullscreen mode

After starting the docker container and backend part, you need to check which containers are launched:

docker ps
Enter fullscreen mode Exit fullscreen mode

You’ll see a list of running containers. Look for the one named like this:

NAMES
your_project-postgres-1
Enter fullscreen mode Exit fullscreen mode

Then you need to enter the container in another terminal

docker exec -ti your_project-postgres-1 bash  
Enter fullscreen mode Exit fullscreen mode

After running the previous command you will enter the container with postgres and you will see the next in the terminal:

root@<container-id>:/#
Enter fullscreen mode Exit fullscreen mode

Connect to the database using:

root@<container-id>:/# psql -U admin
Enter fullscreen mode Exit fullscreen mode

You should now see:

admin=# 
Enter fullscreen mode Exit fullscreen mode

Now we can create a database for the project:

CREATE DATABASE yourprojectdb
Enter fullscreen mode Exit fullscreen mode

To exit the container just type Ctrl+D

There is another way to enter the container and run database queries. In the file your_project/src/backend/queries.sql write a query:

CREATE TABLE messages (
    id SERIAL PRIMARY KEY, -- id for every message
    username TEXT NOT NULL, -- who sent a message
    datetime TIMESTAMP NOT NULL DEFAULT now(), -- when the message was sent
    msgtext TEXT NOT NULL -- text of the message
);
Enter fullscreen mode Exit fullscreen mode

By this query we create a table in the database.

In the terminal run the next line:

psql --user {username} --password -h localhost -p 5432 -f your_project/src/backend/queries.sql
Enter fullscreen mode Exit fullscreen mode

Also, it's easier way to use VSCode Extension like Database Client to run queries and manipulate the database.

If everything it's fine you can send the link to another PC in the local net and chat with another user.

Links and Resources

Top comments (0)