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
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
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"
}
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"
},
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>
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>
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>
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();
};
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
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
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
After starting the docker container and backend part, you need to check which containers are launched:
docker ps
You’ll see a list of running containers. Look for the one named like this:
NAMES
your_project-postgres-1
Then you need to enter the container in another terminal
docker exec -ti your_project-postgres-1 bash
After running the previous command you will enter the container with postgres and you will see the next in the terminal:
root@<container-id>:/#
Connect to the database using:
root@<container-id>:/# psql -U admin
You should now see:
admin=#
Now we can create a database for the project:
CREATE DATABASE yourprojectdb
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
);
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
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.
Top comments (0)