If you’re new to real-time web applications and want to understand the fundamentals before diving into event-driven microservices, this tutorial is for you. We’ll build a simple but fully functional note-sharing app using WebSockets, Go, and Vue.js—perfect for learning the basics.
Why Start Simple?
Before jumping into NATS, Kafka, or complex event-driven architectures, it’s crucial to understand:
- How WebSocket connections work
- Real-time bi-directional communication
- Broadcasting messages to multiple clients
- State management in real-time apps
This tutorial gives you that foundation with minimal dependencies and straightforward code.
What We’re Building
A collaborative note-taking app where:
- Users can create, update, and delete notes
- All changes broadcast to connected clients in real-time
- Simple JWT authentication
- No database (in-memory storage for simplicity)
- Single Go server handling both HTTP and WebSocket
:8080] WS[WebSocket Manager] Store[In-Memory Store] end Vue -->|HTTP API| Server Vue <-->|WebSocket| WS Server --> Store WS --> Store style Server fill:#2c3e50 style WS fill:#e74c3c style Store fill:#27ae60 style Vue fill:#41b883
Project Structure
websocket-notes/
├── backend/
│ ├── main.go
│ ├── handlers.go
│ ├── websocket.go
│ └── store.go
├── frontend/
│ ├── src/
│ │ ├── App.vue
│ │ ├── composables/
│ │ │ ├── useAuth.js
│ │ │ ├── useNotes.js
│ │ │ └── useWebSocket.js
│ │ └── components/
│ │ ├── NoteList.vue
│ │ └── NoteForm.vue
│ └── package.json
└── go.mod
Part 1: Go Backend
Initialize the Project
mkdir websocket-notes && cd websocket-notes
mkdir backend frontend
cd backend
go mod init websocket-notes
go get github.com/gorilla/websocket
go get github.com/golang-jwt/jwt/v5
Backend - main.go
package main
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/websocket"
)
var (
jwtSecret = []byte("change-this-secret-in-production")
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for development
},
}
)
func main() {
// Initialize store and hub
store := NewStore()
hub := NewHub(store)
go hub.Run()
// Routes
http.HandleFunc("/api/register", corsMiddleware(registerHandler(store)))
http.HandleFunc("/api/login", corsMiddleware(loginHandler(store)))
http.HandleFunc("/api/notes", corsMiddleware(authMiddleware(notesHandler(store, hub))))
http.HandleFunc("/ws", wsHandler(hub))
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
// CORS middleware
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
// Auth middleware
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "No authorization header", http.StatusUnauthorized)
return
}
tokenString := authHeader[7:] // Remove "Bearer "
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return jwtSecret, nil
})
if err != nil || !token.Valid {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
claims := token.Claims.(jwt.MapClaims)
r.Header.Set("X-User-ID", claims["user_id"].(string))
next(w, r)
}
}
// Generate JWT token
func generateToken(userID string) (string, error) {
claims := jwt.MapClaims{
"user_id": userID,
"exp": time.Now().Add(24 * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
// JSON response helper
func jsonResponse(w http.ResponseWriter, data interface{}, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
Backend - store.go
package main
import (
"errors"
"sync"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
type User struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"-"`
Created time.Time `json:"created"`
}
type Note struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Title string `json:"title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Store struct {
users map[string]*User
notes map[string]*Note
mu sync.RWMutex
}
func NewStore() *Store {
return &Store{
users: make(map[string]*User),
notes: make(map[string]*Note),
}
}
// User methods
func (s *Store) CreateUser(username, email, password string) (*User, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Check if user exists
for _, u := range s.users {
if u.Email == email {
return nil, errors.New("user already exists")
}
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := &User{
ID: uuid.New().String(),
Username: username,
Email: email,
Password: string(hashedPassword),
Created: time.Now(),
}
s.users[user.ID] = user
return user, nil
}
func (s *Store) GetUserByEmail(email string) (*User, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, u := range s.users {
if u.Email == email {
return u, nil
}
}
return nil, errors.New("user not found")
}
func (s *Store) VerifyPassword(user *User, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
}
// Note methods
func (s *Store) CreateNote(userID, title, content string) *Note {
s.mu.Lock()
defer s.mu.Unlock()
note := &Note{
ID: uuid.New().String(),
UserID: userID,
Title: title,
Content: content,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
s.notes[note.ID] = note
return note
}
func (s *Store) GetAllNotes() []*Note {
s.mu.RLock()
defer s.mu.RUnlock()
notes := make([]*Note, 0, len(s.notes))
for _, note := range s.notes {
notes = append(notes, note)
}
return notes
}
func (s *Store) UpdateNote(id, title, content string) (*Note, error) {
s.mu.Lock()
defer s.mu.Unlock()
note, exists := s.notes[id]
if !exists {
return nil, errors.New("note not found")
}
note.Title = title
note.Content = content
note.UpdatedAt = time.Now()
return note, nil
}
func (s *Store) DeleteNote(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.notes[id]; !exists {
return errors.New("note not found")
}
delete(s.notes, id)
return nil
}
Backend - handlers.go
package main
import (
"encoding/json"
"net/http"
)
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type AuthResponse struct {
Token string `json:"token"`
User *User `json:"user"`
}
type NoteRequest struct {
Title string `json:"title"`
Content string `json:"content"`
}
func registerHandler(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
user, err := store.CreateUser(req.Username, req.Email, req.Password)
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
token, err := generateToken(user.ID)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
jsonResponse(w, AuthResponse{Token: token, User: user}, http.StatusCreated)
}
}
func loginHandler(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
user, err := store.GetUserByEmail(req.Email)
if err != nil || !store.VerifyPassword(user, req.Password) {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
token, err := generateToken(user.ID)
if err != nil {
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
jsonResponse(w, AuthResponse{Token: token, User: user}, http.StatusOK)
}
}
func notesHandler(store *Store, hub *Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID")
switch r.Method {
case http.MethodGet:
notes := store.GetAllNotes()
jsonResponse(w, notes, http.StatusOK)
case http.MethodPost:
var req NoteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
note := store.CreateNote(userID, req.Title, req.Content)
// Broadcast to all connected clients
hub.Broadcast(Message{
Type: "note_created",
Data: note,
})
jsonResponse(w, note, http.StatusCreated)
case http.MethodPut:
var req struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
note, err := store.UpdateNote(req.ID, req.Title, req.Content)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Broadcast update
hub.Broadcast(Message{
Type: "note_updated",
Data: note,
})
jsonResponse(w, note, http.StatusOK)
case http.MethodDelete:
noteID := r.URL.Query().Get("id")
if err := store.DeleteNote(noteID); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
// Broadcast deletion
hub.Broadcast(Message{
Type: "note_deleted",
Data: map[string]string{"id": noteID},
})
jsonResponse(w, map[string]string{"message": "Note deleted"}, http.StatusOK)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
}
Backend - websocket.go
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/gorilla/websocket"
)
type Client struct {
hub *Hub
conn *websocket.Conn
send chan Message
}
type Message struct {
Type string `json:"type"`
Data interface{} `json:"data"`
}
type Hub struct {
clients map[*Client]bool
broadcast chan Message
register chan *Client
unregister chan *Client
store *Store
}
func NewHub(store *Store) *Hub {
return &Hub{
clients: make(map[*Client]bool),
broadcast: make(chan Message, 256),
register: make(chan *Client),
unregister: make(chan *Client),
store: store,
}
}
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.clients[client] = true
log.Printf("Client connected. Total clients: %d", len(h.clients))
// Send current notes to new client
notes := h.store.GetAllNotes()
client.send <- Message{
Type: "initial_notes",
Data: notes,
}
case client := <-h.unregister:
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
log.Printf("Client disconnected. Total clients: %d", len(h.clients))
}
case message := <-h.broadcast:
for client := range h.clients {
select {
case client.send <- message:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}
func (h *Hub) Broadcast(message Message) {
h.broadcast <- message
}
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
for {
_, _, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("WebSocket error: %v", err)
}
break
}
}
}
func (c *Client) writePump() {
defer c.conn.Close()
for message := range c.send {
data, err := json.Marshal(message)
if err != nil {
log.Printf("Failed to marshal message: %v", err)
continue
}
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("Failed to write message: %v", err)
return
}
}
}
func wsHandler(hub *Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
client := &Client{
hub: hub,
conn: conn,
send: make(chan Message, 256),
}
client.hub.register <- client
go client.writePump()
go client.readPump()
}
}
Missing Import
Add this to store.go:
import (
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
And install:
go get github.com/google/uuid
go get golang.org/x/crypto/bcrypt
Part 2: Vue.js Frontend
Setup Frontend
cd ../frontend
npm create vue@latest .
# Select: Vue 3, No TypeScript, Yes to Composition API
npm install axios
Composables - useAuth.js
import { ref } from 'vue'
import axios from 'axios'
const API_URL = 'http://localhost:8080/api'
export function useAuth() {
const user = ref(null)
const token = ref(localStorage.getItem('token') || null)
const loading = ref(false)
const error = ref(null)
const setToken = (newToken) => {
token.value = newToken
localStorage.setItem('token', newToken)
}
const register = async (username, email, password) => {
loading.value = true
error.value = null
try {
const response = await axios.post(`${API_URL}/register`, {
username,
email,
password
})
user.value = response.data.user
setToken(response.data.token)
return true
} catch (err) {
error.value = err.response?.data || 'Registration failed'
return false
} finally {
loading.value = false
}
}
const login = async (email, password) => {
loading.value = true
error.value = null
try {
const response = await axios.post(`${API_URL}/login`, {
email,
password
})
user.value = response.data.user
setToken(response.data.token)
return true
} catch (err) {
error.value = err.response?.data || 'Login failed'
return false
} finally {
loading.value = false
}
}
const logout = () => {
user.value = null
token.value = null
localStorage.removeItem('token')
}
return {
user,
token,
loading,
error,
register,
login,
logout
}
}
Composables - useWebSocket.js
import { ref, onMounted, onUnmounted } from 'vue'
const WS_URL = 'ws://localhost:8080/ws'
export function useWebSocket(onMessage) {
const connected = ref(false)
const ws = ref(null)
const connect = () => {
ws.value = new WebSocket(WS_URL)
ws.value.onopen = () => {
connected.value = true
console.log('WebSocket connected')
}
ws.value.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
onMessage(message)
} catch (err) {
console.error('Failed to parse message:', err)
}
}
ws.value.onclose = () => {
connected.value = false
console.log('WebSocket disconnected')
// Reconnect after 3 seconds
setTimeout(connect, 3000)
}
ws.value.onerror = (error) => {
console.error('WebSocket error:', error)
}
}
const disconnect = () => {
if (ws.value) {
ws.value.close()
}
}
onMounted(connect)
onUnmounted(disconnect)
return {
connected,
disconnect
}
}
Composables - useNotes.js
import { ref } from 'vue'
import axios from 'axios'
const API_URL = 'http://localhost:8080/api'
export function useNotes(token) {
const notes = ref([])
const loading = ref(false)
const error = ref(null)
const headers = () => ({
Authorization: `Bearer ${token.value}`
})
const loadNotes = async () => {
loading.value = true
try {
const response = await axios.get(`${API_URL}/notes`, { headers: headers() })
notes.value = response.data || []
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
const createNote = async (title, content) => {
try {
await axios.post(
`${API_URL}/notes`,
{ title, content },
{ headers: headers() }
)
// Note will be added via WebSocket
} catch (err) {
error.value = err.message
}
}
const updateNote = async (id, title, content) => {
try {
await axios.put(
`${API_URL}/notes`,
{ id, title, content },
{ headers: headers() }
)
// Note will be updated via WebSocket
} catch (err) {
error.value = err.message
}
}
const deleteNote = async (id) => {
try {
await axios.delete(`${API_URL}/notes?id=${id}`, { headers: headers() })
// Note will be removed via WebSocket
} catch (err) {
error.value = err.message
}
}
const handleWebSocketMessage = (message) => {
switch (message.type) {
case 'initial_notes':
notes.value = message.data || []
break
case 'note_created':
notes.value.push(message.data)
break
case 'note_updated':
const updateIndex = notes.value.findIndex(n => n.id === message.data.id)
if (updateIndex !== -1) {
notes.value[updateIndex] = message.data
}
break
case 'note_deleted':
notes.value = notes.value.filter(n => n.id !== message.data.id)
break
}
}
return {
notes,
loading,
error,
loadNotes,
createNote,
updateNote,
deleteNote,
handleWebSocketMessage
}
}
App.vue
<template>
<div id="app">
<header class="header">
<h1>📝 Real-Time Notes</h1>
<div class="status">
<span :class="['badge', connected ? 'connected' : 'disconnected']">
{{ connected ? '🟢 Connected' : '🔴 Disconnected' }}
</span>
</div>
</header>
<!-- Auth Section -->
<div v-if="!user" class="auth-container">
<div class="auth-card">
<h2>{{ showLogin ? 'Login' : 'Register' }}</h2>
<form @submit.prevent="handleAuth">
<input
v-if="!showLogin"
v-model="username"
type="text"
placeholder="Username"
required
/>
<input
v-model="email"
type="email"
placeholder="Email"
required
/>
<input
v-model="password"
type="password"
placeholder="Password"
required
/>
<button type="submit" :disabled="authLoading">
{{ authLoading ? 'Loading...' : (showLogin ? 'Login' : 'Register') }}
</button>
</form>
<p class="toggle">
<a @click="showLogin = !showLogin">
{{ showLogin ? 'Need an account?' : 'Already have an account?' }}
</a>
</p>
<p v-if="authError" class="error">{{ authError }}</p>
</div>
</div>
<!-- Notes Section -->
<div v-else class="main-container">
<div class="user-bar">
<span>Welcome, {{ user.username }}!</span>
<button @click="handleLogout" class="btn-secondary">Logout</button>
</div>
<!-- Create Note -->
<div class="create-note">
<h3>Create New Note</h3>
<form @submit.prevent="handleCreateNote">
<input
v-model="newNote.title"
type="text"
placeholder="Note title"
required
/>
<textarea
v-model="newNote.content"
placeholder="Note content"
rows="4"
required
></textarea>
<button type="submit">Create Note</button>
</form>
</div>
<!-- Notes List -->
<div class="notes-grid">
<div v-if="notes.length === 0" class="empty-state">
<p>No notes yet. Create your first note above!</p>
</div>
<div
v-for="note in sortedNotes"
:key="note.id"
class="note-card"
>
<div v-if="editingNote === note.id" class="edit-form">
<input v-model="editForm.title" type="text" />
<textarea v-model="editForm.content" rows="3"></textarea>
<div class="edit-actions">
<button @click="saveEdit(note.id)" class="btn-save">Save</button>
<button @click="cancelEdit" class="btn-cancel">Cancel</button>
</div>
</div>
<div v-else>
<h3>{{ note.title }}</h3>
<p>{{ note.content }}</p>
<div class="note-footer">
<small>{{ formatDate(note.updated_at) }}</small>
<div class="actions">
<button @click="startEdit(note)" class="btn-small">Edit</button>
<button @click="handleDelete(note.id)" class="btn-small btn-danger">
Delete
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Live Updates Feed -->
<div class="activity-feed">
<h3>🔴 Live Activity</h3>
<div class="activities">
<div
v-for="(activity, index) in recentActivities"
:key="index"
class="activity"
>
<span class="time">{{ activity.time }}</span>
<span class="message">{{ activity.message }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useAuth } from './composables/useAuth'
import { useNotes } from './composables/useNotes'
import { useWebSocket } from './composables/useWebSocket'
const { user, token, loading: authLoading, error: authError, register, login, logout } = useAuth()
const showLogin = ref(true)
const username = ref('')
const email = ref('')
const password = ref('')
const newNote = ref({ title: '', content: '' })
const editingNote = ref(null)
const editForm = ref({ title: '', content: '' })
const recentActivities = ref([])
const { notes, createNote, updateNote, deleteNote, handleWebSocketMessage } = useNotes(token)
const { connected } = useWebSocket((message) => {
handleWebSocketMessage(message)
addActivity(message)
})
const sortedNotes = computed(() => {
return [...notes.value].sort((a, b) =>
new Date(b.updated_at) - new Date(a.updated_at)
)
})
const handleAuth = async () => {
const success = showLogin.value
? await login(email.value, password.value)
: await register(username.value, email.value, password.value)
if (success) {
email.value = ''
password.value = ''
username.value = ''
}
}
const handleLogout = () => {
logout()
notes.value = []
recentActivities.value = []
}
const handleCreateNote = async () => {
await createNote(newNote.value.title, newNote.value.content)
newNote.value = { title: '', content: '' }
}
const startEdit = (note) => {
editingNote.value = note.id
editForm.value = { title: note.title, content: note.content }
}
const saveEdit = async (id) => {
await updateNote(id, editForm.value.title, editForm.value.content)
editingNote.value = null
}
const cancelEdit = () => {
editingNote.value = null
}
const handleDelete = async (id) => {
if (confirm('Are you sure you want to delete this note?')) {
await deleteNote(id)
}
}
const addActivity = (message) => {
const activityMessages = {
note_created: 'New note created',
note_updated: 'Note updated',
note_deleted: 'Note deleted',
}
if (activityMessages[message.type]) {
recentActivities.value.unshift({
time: new Date().toLocaleTimeString(),
message: activityMessages[message.type]
})
if (recentActivities.value.length > 10) {
recentActivities.value.pop()
}
}
}
const formatDate = (dateString) => {
return new Date(dateString).toLocaleString()
}
</script>
<style scoped>
* {
box-sizing: border-box;
}
#app {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.badge {
padding: 6px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.badge.connected {
background: #28a745;
color: white;
}
.badge.disconnected {
background: #dc3545;
color: white;
}
.auth-container {
display: flex;
justify-content: center;
margin-top: 60px;
}
.auth-card {
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
form {
display: flex;
flex-direction: column;
gap: 12px;
}
input,
textarea {
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
button {
padding: 12px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 14px;
}
button:hover {
background: #0056b3;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.toggle {
text-align: center;
margin-top: 16px;
}
.toggle a {
color: #007bff;
cursor: pointer;
text-decoration: underline;
}
.error {
color: #dc3545;
font-size: 14px;
margin-top: 12px;
}
.main-container {
display: grid;
gap: 20px;
}
.user-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
}
.btn-secondary {
background: #6c757d;
padding: 8px 16px;
}
.create-note {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.notes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.note-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.note-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
}
.note-card h3 {
margin: 0 0 12px 0;
color: #333;
}
.note-card p {
color: #666;
line-height: 1.5;
margin: 0 0 16px 0;
}
.note-footer {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid #eee;
padding-top: 12px;
}
.note-footer small {
color: #999;
font-size: 12px;
}
.actions {
display: flex;
gap: 8px;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
}
.btn-danger {
background: #dc3545;
}
.btn-danger:hover {
background: #c82333;
}
.edit-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.edit-actions {
display: flex;
gap: 8px;
}
.btn-save {
background: #28a745;
}
.btn-cancel {
background: #6c757d;
}
.activity-feed {
background: #1a1a1a;
color: white;
padding: 20px;
border-radius: 8px;
max-height: 300px;
overflow-y: auto;
}
.activity-feed h3 {
margin: 0 0 16px 0;
}
.activities {
display: flex;
flex-direction: column;
gap: 8px;
}
.activity {
padding: 8px 12px;
background: #2a2a2a;
border-radius: 4px;
display: flex;
gap: 12px;
font-size: 13px;
}
.time {
color: #888;
font-family: monospace;
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 60px 20px;
color: #999;
}
</style>
Running the Application
Terminal 1 - Backend
cd backend
go run *.go
Terminal 2 - Frontend
cd frontend
npm run dev
Visit http://localhost:5173 in your browser!
How It Works
WebSocket Connection Flow
Key Concepts
1. Hub Pattern The Hub manages all WebSocket connections and broadcasts messages to all connected clients:
type Hub struct {
clients map[*Client]bool // Track all connections
broadcast chan Message // Channel for broadcasting
register chan *Client // New client registration
unregister chan *Client // Client disconnection
}
2. Goroutines for Each Client Each WebSocket connection gets two goroutines:
readPump: Reads messages from the clientwritePump: Writes messages to the client
This prevents blocking and allows concurrent communication.
3. Real-Time Broadcasting When a note is created/updated/deleted:
- HTTP handler processes the request
- Changes saved to store
- Hub broadcasts to all connected clients
- All browsers update instantly
Testing the Real-Time Feature
- Open the app in two different browser windows
- Login with different accounts in each
- Create a note in one window
- Watch it appear instantly in the other window!
Production Improvements
Security
// Add origin checking
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return origin == "https://yourdomain.com"
},
}
// Add authentication to WebSocket
// Extract token from query param or header
token := r.URL.Query().Get("token")
// Validate before upgrading
Database
Replace in-memory store with PostgreSQL:
import (
"database/sql"
_ "github.com/lib/pq"
)
type Store struct {
db *sql.DB
}
func (s *Store) CreateNote(userID, title, content string) (*Note, error) {
query := `INSERT INTO notes (id, user_id, title, content, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`
// Execute query...
}
Rate Limiting
import "golang.org/x/time/rate"
type Client struct {
limiter *rate.Limiter
// ... other fields
}
// In readPump
if !c.limiter.Allow() {
// Reject message
continue
}
Heartbeat/Ping-Pong
func (c *Client) writePump() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case message := <-c.send:
// Write message
case <-ticker.C:
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
Differences from Event-Driven Architecture
| Aspect | This Approach | Event-Driven (NATS) |
|---|---|---|
| Complexity | Simple, single server | Multiple microservices |
| Dependencies | Just Gorilla WebSocket | NATS server + libraries |
| Scaling | Vertical (single server) | Horizontal (multiple services) |
| Use Case | Small to medium apps | Large distributed systems |
| Learning Curve | Beginner-friendly | Intermediate to advanced |
When to Use This Pattern
Good For:
- Small to medium applications
- Real-time dashboards
- Collaborative tools
- Live chat applications
- Notification systems
- Learning WebSockets
Not Ideal For:
- Large-scale microservices
- Complex event processing
- Event sourcing requirements
- High-availability systems needing message persistence
Next Steps
Once you’re comfortable with this pattern:
- Add persistence with PostgreSQL or MongoDB
- Implement Redis for pub/sub across multiple server instances
- Try NATS for event-driven microservices (see our NATS tutorial)
- Add private channels (rooms) for user-specific updates
- Implement message queuing for offline users
Common Issues & Solutions
CORS Errors
Make sure your frontend and backend URLs match the CORS configuration:
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:5173")
WebSocket Connection Fails
Check:
- Backend is running on port 8080
- No firewall blocking WebSocket connections
- Browser console for error messages
Messages Not Broadcasting
Verify:
- Hub.Run() is running in a goroutine
- Broadcast channel isn’t blocked
- All clients are properly registered
Conclusion
This tutorial demonstrates the fundamentals of real-time web applications using WebSockets. You’ve learned:
- ✅ WebSocket connection management
- ✅ Broadcasting to multiple clients
- ✅ Real-time state synchronization
- ✅ Hub pattern for connection management
- ✅ Vue.js composables for clean code organization
The beauty of this approach is its simplicity—no message brokers, no complex infrastructure, just straightforward real-time communication that’s perfect for learning and small to medium applications.
Source Code: GitHub Repository
Related Posts:
- Event-Driven Note Sharing with NATS - Scale this to microservices
- WebSocket Best Practices
- Building Real-Time Apps
Questions? Reach me at [email protected] or @colossus21