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
%%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb'}}}%% graph TB subgraph "Frontend" Vue[Vue.js App] end subgraph "Backend" Server[Go Server
: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

%%{init: {'theme':'dark', 'themeVariables': {'primaryTextColor':'#e5e7eb','nodeTextColor':'#e5e7eb','edgeLabelText':'#e5e7eb'}}}%% sequenceDiagram participant Browser participant Server participant Hub participant Store Browser->>Server: HTTP: Upgrade to WebSocket Server->>Hub: Register new client Hub->>Store: Get all notes Store-->>Hub: Return notes Hub-->>Browser: Send initial_notes Browser->>Server: HTTP: POST /api/notes Server->>Store: Create note Store-->>Server: Return new note Server->>Hub: Broadcast note_created Hub-->>Browser: Push to all clients

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 client
  • writePump: Writes messages to the client

This prevents blocking and allows concurrent communication.

3. Real-Time Broadcasting When a note is created/updated/deleted:

  1. HTTP handler processes the request
  2. Changes saved to store
  3. Hub broadcasts to all connected clients
  4. All browsers update instantly

Testing the Real-Time Feature

  1. Open the app in two different browser windows
  2. Login with different accounts in each
  3. Create a note in one window
  4. 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:

  1. Add persistence with PostgreSQL or MongoDB
  2. Implement Redis for pub/sub across multiple server instances
  3. Try NATS for event-driven microservices (see our NATS tutorial)
  4. Add private channels (rooms) for user-specific updates
  5. 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:


Questions? Reach me at [email protected] or @colossus21