<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ web application - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ web application - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sat, 30 May 2026 14:17:32 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/web-application/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build Real-Time Update Systems with MQTT and Express.js  ]]>
                </title>
                <description>
                    <![CDATA[ Real-time updates are everywhere – like live sports scores, stock tickers, chat applications, and IoT dashboards. If you want to build systems that push data to users the moment it changes, you need t ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-real-time-update-systems-with-mosquitto-and-expressjs/</link>
                <guid isPermaLink="false">69b04145abc0d950017e4629</guid>
                
                    <category>
                        <![CDATA[ Express ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mqtt ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                    <category>
                        <![CDATA[ web application ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ David Aniebo ]]>
                </dc:creator>
                <pubDate>Tue, 10 Mar 2026 16:05:25 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/f39f54d7-d39b-46aa-9046-9a88315369d4.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Real-time updates are everywhere – like live sports scores, stock tickers, chat applications, and IoT dashboards. If you want to build systems that push data to users the moment it changes, you need the right tools.</p>
<p>Message Queuing Telemetry Transport (MQTT) is a lightweight messaging protocol that excels at this. Combined with a broker like Mosquitto and a web framework like Express, you can build a production-ready real-time system in a single afternoon.</p>
<p>In this tutorial, you'll build a complete real-time football (soccer) sports update system from scratch. You'll create an admin interface for uploading scores and match details, a viewer interface for watching live updates, and a backend that uses MQTT to broadcast changes instantly to every connected client.</p>
<p>By the end of this guide, you'll understand how to integrate MQTT with Express, set up the Mosquitto broker, and deliver real-time data to web browsers using Server-Sent Events. You will have a working system that you can extend for production use.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-what-you-will-learn">What You Will Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-understanding-the-architecture">Understanding the Architecture</a></p>
</li>
<li><p><a href="#heading-what-is-mqtt-and-why-use-it">What is MQTT and Why Use It?</a></p>
<ul>
<li><p><a href="#heading-mqtt-topic-design">MQTT Topic Design</a></p>
</li>
<li><p><a href="#heading-why-serversent-events-instead-of-websockets">Why Server-Sent Events Instead of WebSockets?</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-mqtt-broker">How to Set Up the MQTT Broker</a></p>
<ul>
<li><p><a href="#heading-option-1-docker-recommended">Option 1: Docker (Recommended)</a></p>
</li>
<li><p><a href="#heading-option-2-local-install">Option 2: Local Install</a></p>
</li>
<li><p><a href="#heading-option-3-public-test-broker">Option 3: Public Test Broker</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-build-the-express-server">How to Build the Express Server</a></p>
</li>
<li><p><a href="#heading-how-to-implement-the-match-routes">How to Implement the Match Routes</a></p>
</li>
<li><p><a href="#heading-how-to-bridge-mqtt-to-the-browser-with-serversent-events">How to Bridge MQTT to the Browser with Server-Sent Events</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-admin-upload-interface">How to Build the Admin Upload Interface</a></p>
<ul>
<li><p><a href="#heading-admin-html-structure-and-styles">Admin HTML Structure and Styles</a></p>
</li>
<li><p><a href="#heading-admin-javascript-logic">Admin JavaScript Logic</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-build-the-live-viewer-interface">How to Build the Live Viewer Interface</a></p>
<ul>
<li><a href="#heading-viewer-javascript-logic">Viewer JavaScript Logic</a></li>
</ul>
</li>
<li><p><a href="#heading-how-to-build-the-home-page">How to Build the Home Page</a></p>
</li>
<li><p><a href="#heading-how-to-run-and-test-the-system">How to Run and Test the System</a></p>
</li>
<li><p><a href="#heading-how-to-extend-the-system-for-production">How to Extend the System for Production</a></p>
</li>
<li><p><a href="#heading-api-reference">API Reference</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-what-you-will-learn">What You Will Learn</h2>
<p>During this tutorial, you'll learn how to:</p>
<ul>
<li><p>Connect an Express server to an MQTT broker using the MQTT.js library</p>
</li>
<li><p>Publish and subscribe to MQTT topics for real-time messaging</p>
</li>
<li><p>Use Server-Sent Events to push MQTT messages to web browsers</p>
</li>
<li><p>Build a REST application programming interface (API) for match and score management</p>
</li>
<li><p>Create a simple admin interface for uploading match data</p>
</li>
<li><p>Create a viewer interface that updates in real time without page refreshes</p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, you should have:</p>
<ul>
<li><p>Node.js version 18 or later installed on your machine</p>
</li>
<li><p>Basic familiarity with JavaScript, Express, and HTML</p>
</li>
<li><p>A terminal or command line for running commands</p>
</li>
<li><p>Docker installed (optional, for running Mosquitto in a container)</p>
</li>
</ul>
<p>If you don't have Node.js installed, you can download it from the official Node.js website.</p>
<h2 id="heading-understanding-the-architecture">Understanding the Architecture</h2>
<p>The system has three main parts:</p>
<ol>
<li><p><strong>Admin interface</strong> – A web page where you create matches, update scores, and add events such as goals and cards.</p>
</li>
<li><p><strong>Express server</strong> – Receives HyperText Transfer Protocol (HTTP) requests from the admin, publishes data to MQTT, subscribes to MQTT topics, and streams updates to viewers via Server-Sent Events.</p>
</li>
<li><p><strong>Viewer interface</strong> – A web page that connects to the server and displays live scores and events as they arrive.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/6904c2dbd42ef6b1f9e61c3e/e6d0b007-764d-41bf-8d05-9e37971bd78e.png" alt="Architecture-diagram" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><strong>How the flow works</strong><br>When you submit a score update in the admin panel, the Express server publishes a message to the MQTT broker. The server also subscribes to those same topics. When a message arrives, the server forwards it to all connected viewers through Server-Sent Events. The viewers update their display without refreshing the page.</p>
<h2 id="heading-what-is-mqtt-and-why-use-it">What is MQTT and Why Use It?</h2>
<p>MQTT stands for Message Queuing Telemetry Transport. It's a lightweight, publish-subscribe messaging protocol designed for low bandwidth and unreliable networks. It's widely used in Internet of Things (IoT) applications, but it works well for any real-time system where you need to broadcast updates to many subscribers.</p>
<p>Here are some reasons to use MQTT for a sports update system:</p>
<ul>
<li><p><strong>Low overhead</strong>: Messages are small and efficient, which helps when you have many clients.</p>
</li>
<li><p><strong>Built-in Quality of Service (QoS)</strong>: You can choose how many times a message is delivered (at most once, at least once, or exactly once).</p>
</li>
<li><p><strong>Topic-based routing</strong>: You organize messages by topic (for example, <code>sports/football/match/123</code>) so subscribers receive only what they need.</p>
</li>
<li><p><strong>Broker-based</strong>: A central broker (Mosquitto) handles all message distribution, so your application logic stays simple.</p>
</li>
</ul>
<p>Mosquitto is a popular, open-source MQTT broker that's easy to install and configure.</p>
<h3 id="heading-mqtt-topic-design">MQTT Topic Design</h3>
<p>MQTT uses a hierarchical topic structure. For this project, the topics are:</p>
<ul>
<li><p><code>sports/football/match/{id}</code>: One topic per match. When you publish the full match object here, any subscriber receives the complete state. This makes it easy to add new match fields later without changing the topic structure.</p>
</li>
<li><p><code>sports/football/scores</code>: A single topic for score-related notifications. Messages include a <code>type</code> field (<code>match_created</code> or <code>score_update</code>) so subscribers can handle them differently.</p>
</li>
<li><p><code>sports/football/events</code>: A topic for match events such as goals and cards. Subscribers receive <code>{ type: 'match_event', matchId, event }</code>.</p>
</li>
</ul>
<p>The <code>#</code> wildcard in a subscription means "match this level and all levels below." So <code>sports/football/#</code> subscribes to every topic under <code>sports/football</code>, including <code>sports/football/match/abc123</code> and <code>sports/football/scores</code>. The <code>+</code> wildcard matches exactly one level. For example, <code>sports/+/match/#</code> would match any sport, not just football.</p>
<h3 id="heading-why-server-sent-events-instead-of-websockets">Why Server-Sent Events Instead of WebSockets?</h3>
<p>You might wonder why this tutorial uses Server-Sent Events (SSE) instead of WebSockets. Both can push data to the browser. The main difference:</p>
<ul>
<li><p><strong>Server-Sent Events</strong>: One-way (server to client). Built on HTTP. Automatic reconnection in the browser. Simpler to implement. No extra libraries.</p>
</li>
<li><p><strong>WebSockets</strong>: Two-way. Requires a different protocol. More flexible but more complex.</p>
</li>
</ul>
<p>For a sports score viewer, you only need server-to-client updates. The viewer never sends messages back over the same channel. Server-Sent Events is a better fit. If you later need the client to send commands (for example, to filter by league), you can add a separate HTTP API or switch to WebSockets.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Start by creating a new folder for your project and initialize it with npm. The <code>mkdir</code> command creates the directory, <code>cd</code> moves into it, and <code>npm init -y</code> creates a <code>package.json</code> file with default values without prompting you for input.</p>
<pre><code class="language-shell">mkdir mqtt-football-scores
cd mqtt-football-scores
npm init -y
</code></pre>
<p>Install the required dependencies. Each package serves a specific role in the application. Run this command in the project root directory.</p>
<pre><code class="language-shell">npm install express cors mqtt uuid
</code></pre>
<ul>
<li><p><code>express</code>: Web framework for the HTTP server and API. It provides routing, middleware, and static file serving.</p>
</li>
<li><p><code>cors</code>: Enables Cross-Origin Resource Sharing so your frontend can call the API from a different origin (for example, if you serve the HTML from a different port or domain).</p>
</li>
<li><p><code>mqtt</code>: MQTT client for Node.js. It handles connection, publish, subscribe, reconnection, and Quality of Service (QoS) flows.</p>
</li>
<li><p><code>uuid</code>: Generates unique identifiers (Universally Unique Identifiers) for matches and events. Each ID is practically unique across all systems.</p>
</li>
</ul>
<p>Next, create the following folder structure. The <code>server</code> folder holds the Node.js backend code. The <code>public</code> folder holds the HTML, CSS, and client-side JavaScript that the browser loads. The <code>routes</code> subfolder keeps the match-related route handlers separate from the main server file.</p>
<pre><code class="language-plaintext">mqtt-football-scores/
├── server/
│   ├── index.js
│   ├── sse.js
│   └── routes/
│       └── matches.js
├── public/
│   ├── index.html
│   ├── admin.html
│   └── viewer.html
├── mosquitto.conf
├── docker-compose.yml
└── package.json
</code></pre>
<p>Add <code>"type": "module"</code> to your <code>package.json</code> so you can use JavaScript modules (import and export). The <code>type</code> field tells Node.js to treat <code>.js</code> files as ES modules, which allows you to use <code>import</code> and <code>export</code> syntax instead of CommonJS <code>require</code> and <code>module.exports</code>.</p>
<pre><code class="language-json">{
  "name": "mqtt-football-scores",
  "version": "1.0.0",
  "type": "module",
  "main": "server/index.js"
}
</code></pre>
<h2 id="heading-how-to-set-up-the-mqtt-broker">How to Set Up the MQTT Broker</h2>
<p>You need an MQTT broker running before the server can connect. You have three options.</p>
<h3 id="heading-option-1-docker-recommended">Option 1: Docker (Recommended)</h3>
<p>Create a <code>docker-compose.yml</code> file. This file defines a single service named <code>mosquitto</code> that runs the Eclipse Mosquitto 2 image. The <code>ports</code> directive maps port 1883 on your host to port 1883 in the container so your Express server can connect. The <code>volumes</code> directive mounts your local <code>mosquitto.conf</code> into the container so the broker uses your configuration. The <code>restart: unless-stopped</code> option ensures the container restarts automatically if it crashes or if you reboot your machine.</p>
<pre><code class="language-yaml">version: "3.8"

services:
  mosquitto:
    image: eclipse-mosquitto:2
    container_name: mqtt-football-mosquitto
    ports:
      - "1883:1883"
    volumes:
      - ./mosquitto.conf:/mosquitto/config/mosquitto.conf
    restart: unless-stopped
</code></pre>
<p>Create a <code>mosquitto.conf</code> file. The <code>listener 1883</code> directive tells Mosquitto to listen on port 1883 for MQTT connections. The <code>protocol mqtt</code> specifies the standard MQTT protocol (as opposed to WebSocket). The <code>allow_anonymous true</code> setting permits connections without a username and password, which is fine for local development but should be disabled in production. The <code>log_dest stdout</code> and <code>log_type all</code> directives send all log output to the console so you can debug connection issues.</p>
<pre><code class="language-plaintext">listener 1883
protocol mqtt
allow_anonymous true
log_dest stdout
log_type all
</code></pre>
<p>Start the broker:</p>
<pre><code class="language-bash">docker-compose up -d
</code></pre>
<h3 id="heading-option-2-local-install">Option 2: Local Install</h3>
<p>On macOS with Homebrew:</p>
<pre><code class="language-bash">brew install mosquitto
mosquitto -c mosquitto.conf
</code></pre>
<p>On Ubuntu or Debian:</p>
<pre><code class="language-bash">sudo apt install mosquitto mosquitto-clients
sudo systemctl start mosquitto
</code></pre>
<h3 id="heading-option-3-public-test-broker">Option 3: Public Test Broker</h3>
<p>You can use the public test broker at <code>test.mosquitto.org</code> without installing anything. Set the environment variable when you start the server:</p>
<pre><code class="language-bash">MQTT_BROKER=mqtt://test.mosquitto.org npm start
</code></pre>
<p>Note: The public broker is shared and not suitable for production. Use it only for development and testing.</p>
<h2 id="heading-how-to-build-the-express-server">How to Build the Express Server</h2>
<p>In this step, you'll create the main server that powers the real-time application. The Express server handles HTTP requests, serves static files like HTML and JavaScript, and acts as the bridge between the browser and the MQTT broker. It also provides endpoints that allow data to be sent and received in real time. Essentially, this server is the backbone of the application, enabling communication between all components.</p>
<p>Begin by creating the main server file at <code>server/index.js</code>:</p>
<pre><code class="language-typescript">import express from 'express';
import cors from 'cors';
import mqtt from 'mqtt';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { v4 as uuidv4 } from 'uuid';

import { matchRoutes } from './routes/matches.js';
import { setupSSE, addSSEClient } from './sse.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const MQTT_BROKER = process.env.MQTT_BROKER || 'mqtt://localhost:1883';
const PORT = process.env.PORT || 3000;

const app = express();
app.use(cors());
app.use(express.json());
app.use(express.static(join(__dirname, '../public')));

let mqttClient = null;

function connectMQTT() {
  mqttClient = mqtt.connect(MQTT_BROKER, {
    clientId: `football-scores-${uuidv4().slice(0, 8)}`,
    reconnectPeriod: 3000,
    connectTimeout: 10000,
  });

  mqttClient.on('connect', () =&gt; {
    console.log('Connected to MQTT broker at', MQTT_BROKER);
    mqttClient.subscribe('sports/football/#', { qos: 1 }, (err) =&gt; {
      if (err) console.error('Subscribe error:', err);
    });
  });

  mqttClient.on('error', (err) =&gt; {
    console.error('MQTT error:', err.message);
  });

  mqttClient.on('close', () =&gt; {
    console.log('MQTT connection closed');
  });

  mqttClient.on('reconnect', () =&gt; {
    console.log('MQTT reconnecting...');
  });

  return mqttClient;
}

const mqttClientInstance = connectMQTT();
const { publishMatch, publishScoreUpdate, publishEvent, getMatches } = matchRoutes(mqttClientInstance);
setupSSE(mqttClientInstance);

app.get('/api/events', (req, res) =&gt; addSSEClient(res));
app.post('/api/matches', publishMatch);
app.patch('/api/matches/:id/score', publishScoreUpdate);
app.post('/api/matches/:id/events', publishEvent);
app.get('/api/matches', getMatches);

app.listen(PORT, () =&gt; {
  console.log(`Football Scores Server running at http://localhost:${PORT}`);
  console.log(`Admin (upload):  http://localhost:${PORT}/admin.html`);
  console.log(`Viewer:          http://localhost:${PORT}/viewer.html`);
});
</code></pre>
<p>Here is what each part does:</p>
<ul>
<li><p><strong>Imports</strong>: The <code>fileURLToPath</code> and <code>dirname</code> utilities replicate the <code>__dirname</code> variable that CommonJS provides, since ES modules don't have it. You need <code>__dirname</code> to build the path to the <code>public</code> folder.</p>
</li>
<li><p><strong>Environment variables</strong>: <code>MQTT_BROKER</code> defaults to <code>mqtt://localhost:1883</code> so the server connects to a local Mosquitto instance. You can override it for Docker or a remote broker. <code>PORT</code> defaults to 3000.</p>
</li>
<li><p><strong>Middleware</strong>: <code>cors()</code> allows requests from any origin, which is useful during development. <code>express.json()</code> parses JSON request bodies. <code>express.static()</code> serves files from <code>public</code> so <code>/admin.html</code> and <code>/viewer.html</code> are available.</p>
</li>
<li><p><strong>connectMQTT</strong>: Creates an MQTT client with a unique client ID (required by the broker), a 3-second reconnect interval, and a 10-second connection timeout. On connect, it subscribes to <code>sports/football/#</code> with QoS 1. The <code>#</code> wildcard means "all topics under sports/football."</p>
</li>
<li><p><strong>matchRoutes</strong>: Returns the route handlers that create matches, update scores, and add events. Each handler publishes to MQTT and responds with JSON.</p>
</li>
<li><p><strong>setupSSE</strong>: Registers a listener on the MQTT client's <code>message</code> event. When a message arrives, it forwards the payload to all connected Server-Sent Events clients.</p>
</li>
<li><p><strong>addSSEClient</strong>: Called when a viewer opens <code>/api/events</code>. It sets the response headers for Server-Sent Events, flushes the headers so the connection stays open, and adds the response object to a set of active clients.</p>
</li>
<li><p><strong>Routes</strong>: The GET <code>/api/events</code> route establishes the Server-Sent Events stream. The POST, PATCH, and GET routes for matches delegate to the handlers from <code>matchRoutes</code>.</p>
</li>
</ul>
<p>The server serves static files from the <code>public</code> folder, so your HTML pages are available at the root.</p>
<h2 id="heading-how-to-implement-the-match-routes">How to Implement the Match Routes</h2>
<p>In this step, you'll create the route handlers that manage matches, scores, and events. These routes allow the server to receive requests from the admin interface, update match data, and publish real-time updates to MQTT. They also store match information in memory so it can be retrieved and updated during the session.</p>
<p>Begin by creating the file at <code>server/routes/matches.js</code>:</p>
<pre><code class="language-javascript">import { v4 as uuidv4 } from 'uuid';

const TOPIC_MATCH = 'sports/football/match';
const TOPIC_SCORES = 'sports/football/scores';
const TOPIC_EVENTS = 'sports/football/events';

const matches = new Map();

function publish(client, topic, payload, qos = 1) {
  if (!client?.connected) {
    console.warn('MQTT not connected, message not published');
    return false;
  }
  client.publish(topic, JSON.stringify(payload), { qos, retain: false });
  return true;
}

export function matchRoutes(mqttClient) {
  return {
    publishMatch: (req, res) =&gt; {
      const { homeTeam, awayTeam, league, venue, kickoff } = req.body;
      if (!homeTeam || !awayTeam) {
        return res.status(400).json({ error: 'homeTeam and awayTeam are required' });
      }

      const match = {
        id: uuidv4(),
        homeTeam,
        awayTeam,
        homeScore: 0,
        awayScore: 0,
        league: league || 'Premier League',
        venue: venue || 'TBD',
        kickoff: kickoff || new Date().toISOString(),
        status: 'scheduled',
        minute: 0,
        events: [],
        createdAt: new Date().toISOString(),
      };

      matches.set(match.id, match);

      const topic = `\({TOPIC_MATCH}/\){match.id}`;
      publish(mqttClient, topic, match);
      publish(mqttClient, TOPIC_SCORES, { type: 'match_created', match });

      res.status(201).json(match);
    },

    publishScoreUpdate: (req, res) =&gt; {
      const { id } = req.params;
      const { homeScore, awayScore, minute, status } = req.body;

      const match = matches.get(id);
      if (!match) {
        return res.status(404).json({ error: 'Match not found' });
      }

      if (homeScore !== undefined) match.homeScore = homeScore;
      if (awayScore !== undefined) match.awayScore = awayScore;
      if (minute !== undefined) match.minute = minute;
      if (status !== undefined) match.status = status;

      const topic = `\({TOPIC_MATCH}/\){id}`;
      publish(mqttClient, topic, match);
      publish(mqttClient, TOPIC_SCORES, {
        type: 'score_update',
        matchId: id,
        homeScore: match.homeScore,
        awayScore: match.awayScore,
        minute: match.minute,
        status: match.status,
      });

      res.json(match);
    },

    publishEvent: (req, res) =&gt; {
      const { id } = req.params;
      const { type, team, player, minute, description } = req.body;

      const match = matches.get(id);
      if (!match) {
        return res.status(404).json({ error: 'Match not found' });
      }

      const event = {
        id: uuidv4().slice(0, 8),
        type: type || 'goal',
        team,
        player: player || 'Unknown',
        minute: minute ?? match.minute,
        description: description || `\({type}: \){player}`,
        timestamp: new Date().toISOString(),
      };

      match.events.push(event);
      if (type === 'goal') {
        if (team === match.homeTeam) match.homeScore++;
        else if (team === match.awayTeam) match.awayScore++;
      }

      const topic = `\({TOPIC_MATCH}/\){id}`;
      publish(mqttClient, topic, match);
      publish(mqttClient, TOPIC_EVENTS, { type: 'match_event', matchId: id, event });

      res.status(201).json({ match, event });
    },

    getMatches: (req, res) =&gt; {
      const list = Array.from(matches.values()).sort(
        (a, b) =&gt; new Date(b.createdAt) - new Date(a.createdAt)
      );
      res.json(list);
    },
  };
}
</code></pre>
<p>The routes use an in-memory <code>Map</code> to store matches. In production, you would replace this with a database such as PostgreSQL or MongoDB.</p>
<p>Explanation of the key logic:</p>
<ul>
<li><p><strong>publish</strong>: A helper that checks if the MQTT client is connected before publishing. If the broker is down, it logs a warning instead of throwing. The <code>retain: false</code> option means the broker doesn't store the last message for new subscribers.</p>
</li>
<li><p><strong>publishMatch</strong>: Validates that <code>homeTeam</code> and <code>awayTeam</code> are present. Creates a match object with default values for league, venue, kickoff, status, and events. Stores it in the Map, publishes to the match-specific topic and the scores topic, and returns the match with status 201 (Created).</p>
</li>
<li><p><strong>publishScoreUpdate</strong>: Looks up the match by ID. If not found, returns 404. Updates only the fields that are provided (using <code>!== undefined</code> so you can set a score to 0). Publishes the full match and a score_update notification.</p>
</li>
<li><p><strong>publishEvent</strong>: Creates an event object with a short unique ID. Pushes it to the match's events array. If the event type is <code>goal</code>, increments the home or away score based on the team. Publishes the updated match and an event notification.</p>
</li>
<li><p><strong>getMatches</strong>: Converts the Map to an array, sorts by <code>createdAt</code> descending (newest first), and returns the list as JSON.</p>
</li>
</ul>
<p>MQTT topics used:</p>
<ul>
<li><p><code>sports/football/match/{id}</code>: Full match state. Used when a match is created or updated.</p>
</li>
<li><p><code>sports/football/scores</code>: Score change notifications. Used for match creation and score updates.</p>
</li>
<li><p><code>sports/football/events</code>: Match events such as goals and cards.</p>
</li>
</ul>
<p>The <code>publish</code> function sends JavaScript Object Notation (JSON) payloads with QoS 1 (at least once delivery). MQTT defines three QoS levels: 0 (at most once), 1 (at least once), and 2 (exactly once). Level 1 ensures the broker will retry until the subscriber acknowledges the message, which reduces the chance of losing score updates if the connection drops briefly.</p>
<h2 id="heading-how-to-bridge-mqtt-to-the-browser-with-server-sent-events">How to Bridge MQTT to the Browser with Server-Sent Events</h2>
<p>In this step, you'll create the Server-Sent Events (SSE) module that bridges MQTT messages to the browser. MQTT works over TCP and browsers cannot connect to it directly, so we use SSE as a lightweight HTTP-based streaming channel. SSE keeps an open connection and allows the server to push updates to connected browsers in real time. This is what enables viewers to see match updates instantly without refreshing.</p>
<p>Now create the file at <code>server/sse.js</code>:</p>
<pre><code class="language-javascript">const clients = new Set();

export function setupSSE(mqttClient) {
  if (!mqttClient) return;

  mqttClient.on('message', (topic, message) =&gt; {
    try {
      const payload = JSON.parse(message.toString());
      const data = JSON.stringify({ topic, ...payload });
      clients.forEach((res) =&gt; {
        try {
          res.write(`data: ${data}\n\n`);
        } catch (e) {
          clients.delete(res);
        }
      });
    } catch (e) {
      console.error('SSE parse error:', e.message);
    }
  });
}

export function addSSEClient(res) {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');
  res.flushHeaders();

  clients.add(res);

  res.on('close', () =&gt; {
    clients.delete(res);
  });
}
</code></pre>
<p>Explanation of each part:</p>
<ul>
<li><p><strong>clients Set</strong>: A Set holds all active Server-Sent Events response objects. Using a Set makes it easy to add and remove clients without duplicates.</p>
</li>
<li><p><strong>setupSSE</strong>: Attaches a <code>message</code> listener to the MQTT client. When any message arrives on a subscribed topic, the callback runs. It parses the message payload (which is JSON), merges the topic into the payload with <code>{ topic, ...payload }</code>, and sends the result to every client. The Server-Sent Events format requires each message to be <code>data: {content}\n\n</code> (two newlines). The <code>forEach</code> loop catches write errors (for example, if a client disconnected) and removes the client from the set.</p>
</li>
<li><p><strong>addSSEClient</strong>: Sets the <code>Content-Type</code> header to <code>text/event-stream</code> so the browser treats the response as an event stream. The <code>Cache-Control: no-cache</code> and <code>Connection: keep-alive</code> headers prevent the browser or proxy from caching or closing the connection. The <code>X-Accel-Buffering: no</code> header disables buffering in Nginx, which can delay or block Server-Sent Events. The <code>flushHeaders</code> call sends the headers immediately so the connection is established. The <code>close</code> event handler removes the client when the client disconnects (closes the tab or navigates away).</p>
</li>
</ul>
<p>Server-Sent Events is one-way (server to client). For this use case, that is enough because viewers only need to receive updates, not send messages back over the same channel.</p>
<h2 id="heading-how-to-build-the-admin-upload-interface">How to Build the Admin Upload Interface</h2>
<p>The admin interface is a single HTML page where match creators can create new matches, update scores, and add events such as goals or cards. It uses standard HTML forms so data can be submitted to the server, and JavaScript will later handle form submissions and dynamic updates. All the markup, styles, and script live in <code>public/admin.html</code>, which the Express server serves as a static page.</p>
<h3 id="heading-admin-html-structure-and-styles">Admin HTML Structure and Styles</h3>
<p>The document starts with the standard HTML5 boilerplate. The <code>charset</code> and <code>viewport</code> meta tags ensure proper character encoding and responsive layout on mobile devices. The page loads the Outfit font from Google Fonts for a clean, modern look.</p>
<p>The Cascading Style Sheets (CSS) uses custom properties (variables) in the <code>:root</code> block so you can change the color scheme in one place. The <code>--bg</code> variable holds the dark background color, <code>--surface</code> for card backgrounds, <code>--accent</code> for the green accent color, and <code>--text-muted</code> for secondary text. The <code>.grid</code> class creates a two-column layout for form fields that collapses to one column on screens under 600 pixels wide. The <code>.toast</code> class positions the notification at the bottom-right and uses <code>transform</code> and <code>opacity</code> for a slide-in animation when the <code>.show</code> class is added.</p>
<p>Now create the file at <code>public/admin.html</code>:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
  &lt;meta charset="UTF-8"&gt;
  &lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
  &lt;title&gt;Admin - Football Scores Upload&lt;/title&gt;
  &lt;link rel="preconnect" href="https://fonts.googleapis.com"&gt;
  &lt;link rel="preconnect" href="https://fonts.gstatic.com" crossorigin&gt;
  &lt;link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&amp;display=swap" rel="stylesheet"&gt;
  &lt;style&gt;
    :root {
      --bg: #0f1419;
      --surface: #1a2332;
      --surface-hover: #243044;
      --accent: #00d26a;
      --accent-dim: #00a854;
      --text: #e8edf2;
      --text-muted: #8b9aab;
      --border: #2d3a4d;
      --danger: #ff4757;
    }
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: 'Outfit', sans-serif;
      background: var(--bg);
      color: var(--text);
      min-height: 100vh;
      padding: 2rem;
    }
    .container { max-width: 900px; margin: 0 auto; }
    header {
      display: flex;
      align-items: center;
      gap: 1rem;
      margin-bottom: 2rem;
      padding-bottom: 1rem;
      border-bottom: 1px solid var(--border);
    }
    section {
      background: var(--surface);
      border-radius: 12px;
      padding: 1.5rem;
      margin-bottom: 1.5rem;
      border: 1px solid var(--border);
    }
    .grid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 1rem;
    }
    @media (max-width: 600px) { .grid { grid-template-columns: 1fr; } }
    .form-group { display: flex; flex-direction: column; gap: 0.4rem; }
    .form-group.full { grid-column: 1 / -1; }
    input, select {
      padding: 0.65rem 1rem;
      border: 1px solid var(--border);
      border-radius: 8px;
      background: var(--bg);
      color: var(--text);
      font-family: inherit;
    }
    button {
      padding: 0.75rem 1.5rem;
      border: none;
      border-radius: 8px;
      font-family: inherit;
      font-weight: 600;
      cursor: pointer;
    }
    .btn-primary { background: var(--accent); color: var(--bg); }
    .btn-secondary { background: var(--surface-hover); color: var(--text); border: 1px solid var(--border); }
    .actions { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-top: 1rem; }
    .badge { background: var(--accent); color: var(--bg); font-size: 0.75rem; padding: 0.25rem 0.6rem; border-radius: 999px; font-weight: 600; }
    .viewer-link { margin-left: auto; color: var(--accent); text-decoration: none; font-weight: 500; }
    section h2 { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; color: var(--text-muted); }
    label { font-size: 0.85rem; font-weight: 500; color: var(--text-muted); }
    .toast {
      position: fixed;
      bottom: 2rem;
      right: 2rem;
      padding: 1rem 1.5rem;
      border-radius: 8px;
      font-weight: 500;
      color: var(--bg);
      background: var(--accent);
      transform: translateY(100px);
      opacity: 0;
      transition: transform 0.3s, opacity 0.3s;
      z-index: 100;
    }
    .toast.show { transform: translateY(0); opacity: 1; }
    .toast.error { background: var(--danger); }
    .match-card {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 1rem;
      background: var(--bg);
      border-radius: 8px;
      margin-bottom: 0.5rem;
      border: 1px solid var(--border);
    }
    .match-score { font-size: 1.5rem; font-weight: 700; color: var(--accent); margin: 0 1rem; }
    .match-info { flex: 1; }
    .match-teams { font-weight: 600; font-size: 1rem; }
    .match-meta { font-size: 0.8rem; color: var(--text-muted); margin-top: 0.25rem; }
    .match-actions { display: flex; gap: 0.5rem; }
    .match-actions button { padding: 0.5rem 1rem; font-size: 0.85rem; }
    .match-list { margin-top: 1rem; }
  &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;div class="container"&gt;
    &lt;header&gt;
      &lt;h1&gt;⚽ Football Scores&lt;/h1&gt;
      &lt;span class="badge"&gt;Admin&lt;/span&gt;
      &lt;a href="/viewer.html" class="viewer-link"&gt;→ Open Viewer&lt;/a&gt;
    &lt;/header&gt;

    &lt;section&gt;
      &lt;h2&gt;Create New Match&lt;/h2&gt;
      &lt;form id="createMatch"&gt;
        &lt;div class="grid"&gt;
          &lt;div class="form-group"&gt;
            &lt;label for="homeTeam"&gt;Home Team&lt;/label&gt;
            &lt;input type="text" id="homeTeam" placeholder="e.g. Manchester United" required&gt;
          &lt;/div&gt;
          &lt;div class="form-group"&gt;
            &lt;label for="awayTeam"&gt;Away Team&lt;/label&gt;
            &lt;input type="text" id="awayTeam" placeholder="e.g. Liverpool" required&gt;
          &lt;/div&gt;
          &lt;div class="form-group"&gt;
            &lt;label for="league"&gt;League&lt;/label&gt;
            &lt;input type="text" id="league" placeholder="e.g. Premier League" value="Premier League"&gt;
          &lt;/div&gt;
          &lt;div class="form-group"&gt;
            &lt;label for="venue"&gt;Venue&lt;/label&gt;
            &lt;input type="text" id="venue" placeholder="e.g. Old Trafford"&gt;
          &lt;/div&gt;
          &lt;div class="form-group full"&gt;
            &lt;label for="kickoff"&gt;Kickoff (ISO)&lt;/label&gt;
            &lt;input type="datetime-local" id="kickoff"&gt;
          &lt;/div&gt;
        &lt;/div&gt;
        &lt;div class="actions"&gt;
          &lt;button type="submit" class="btn-primary"&gt;Create Match&lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/section&gt;

    &lt;section&gt;
      &lt;h2&gt;Update Score&lt;/h2&gt;
      &lt;form id="updateScore"&gt;
        &lt;div class="grid"&gt;
          &lt;div class="form-group full"&gt;
            &lt;label for="scoreMatchId"&gt;Select Match&lt;/label&gt;
            &lt;select id="scoreMatchId" required&gt;
              &lt;option value=""&gt;-- Select match --&lt;/option&gt;
            &lt;/select&gt;
          &lt;/div&gt;
          &lt;div class="form-group"&gt;
            &lt;label for="homeScore"&gt;Home Score&lt;/label&gt;
            &lt;input type="number" id="homeScore" min="0" value="0"&gt;
          &lt;/div&gt;
          &lt;div class="form-group"&gt;
            &lt;label for="awayScore"&gt;Away Score&lt;/label&gt;
            &lt;input type="number" id="awayScore" min="0" value="0"&gt;
          &lt;/div&gt;
          &lt;div class="form-group"&gt;
            &lt;label for="minute"&gt;Minute&lt;/label&gt;
            &lt;input type="number" id="minute" min="0" placeholder="e.g. 67"&gt;
          &lt;/div&gt;
          &lt;div class="form-group"&gt;
            &lt;label for="status"&gt;Status&lt;/label&gt;
            &lt;select id="status"&gt;
              &lt;option value="scheduled"&gt;Scheduled&lt;/option&gt;
              &lt;option value="live"&gt;Live&lt;/option&gt;
              &lt;option value="halftime"&gt;Halftime&lt;/option&gt;
              &lt;option value="finished"&gt;Finished&lt;/option&gt;
            &lt;/select&gt;
          &lt;/div&gt;
        &lt;/div&gt;
        &lt;div class="actions"&gt;
          &lt;button type="submit" class="btn-primary"&gt;Update Score&lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/section&gt;

    &lt;section&gt;
      &lt;h2&gt;Add Match Event (Goal, Card, etc.)&lt;/h2&gt;
      &lt;form id="addEvent"&gt;
        &lt;div class="grid"&gt;
          &lt;div class="form-group full"&gt;
            &lt;label for="eventMatchId"&gt;Select Match&lt;/label&gt;
            &lt;select id="eventMatchId" required&gt;
              &lt;option value=""&gt;-- Select match --&lt;/option&gt;
            &lt;/select&gt;
          &lt;/div&gt;
          &lt;div class="form-group"&gt;
            &lt;label for="eventType"&gt;Event Type&lt;/label&gt;
            &lt;select id="eventType"&gt;
              &lt;option value="goal"&gt;Goal&lt;/option&gt;
              &lt;option value="yellow_card"&gt;Yellow Card&lt;/option&gt;
              &lt;option value="red_card"&gt;Red Card&lt;/option&gt;
              &lt;option value="substitution"&gt;Substitution&lt;/option&gt;
              &lt;option value="penalty"&gt;Penalty&lt;/option&gt;
            &lt;/select&gt;
          &lt;/div&gt;
          &lt;div class="form-group"&gt;
            &lt;label for="eventTeam"&gt;Team&lt;/label&gt;
            &lt;input type="text" id="eventTeam" placeholder="e.g. Manchester United"&gt;
          &lt;/div&gt;
          &lt;div class="form-group"&gt;
            &lt;label for="eventPlayer"&gt;Player&lt;/label&gt;
            &lt;input type="text" id="eventPlayer" placeholder="e.g. Marcus Rashford"&gt;
          &lt;/div&gt;
          &lt;div class="form-group"&gt;
            &lt;label for="eventMinute"&gt;Minute&lt;/label&gt;
            &lt;input type="number" id="eventMinute" min="0" placeholder="e.g. 23"&gt;
          &lt;/div&gt;
          &lt;div class="form-group full"&gt;
            &lt;label for="eventDesc"&gt;Description&lt;/label&gt;
            &lt;input type="text" id="eventDesc" placeholder="Optional description"&gt;
          &lt;/div&gt;
        &lt;/div&gt;
        &lt;div class="actions"&gt;
          &lt;button type="submit" class="btn-primary"&gt;Add Event&lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/section&gt;

    &lt;section&gt;
      &lt;h2&gt;Active Matches&lt;/h2&gt;
      &lt;div class="match-list" id="matchList"&gt;&lt;/div&gt;
    &lt;/section&gt;
  &lt;/div&gt;

  &lt;div class="toast" id="toast"&gt;&lt;/div&gt;
</code></pre>
<p>The three forms use <code>id</code> attributes (<code>createMatch</code>, <code>updateScore</code>, <code>addEvent</code>) so the JavaScript can attach submit handlers. The match dropdowns (<code>scoreMatchId</code> and <code>eventMatchId</code>) are populated dynamically when the page loads. The <code>matchList</code> div is the container for the list of active matches. The toast element sits outside the main container so it can be fixed to the viewport.</p>
<h3 id="heading-admin-javascript-logic">Admin JavaScript Logic</h3>
<p>The script block handles all interaction between the admin page and the server. It defines helper functions for showing notifications, fetching matches, updating the UI, and submitting data. When the page loads or after any action (create, update, or event submission), the UI refreshes so the latest match data is always visible.</p>
<pre><code class="language-javascript">  &lt;script&gt;
    const API = '/api';
    const toast = document.getElementById('toast');

    function showToast(msg, isError = false) {
      toast.textContent = msg;
      toast.className = 'toast show' + (isError ? ' error' : '');
      setTimeout(() =&gt; toast.classList.remove('show'), 3000);
    }

    async function fetchMatches() {
      const res = await fetch(`${API}/matches`);
      return res.json();
    }

    function populateSelects(matches) {
      const opts = matches.map(m =&gt; `&lt;option value="\({m.id}"&gt;\){m.homeTeam} vs ${m.awayTeam}&lt;/option&gt;`).join('');
      const html = '&lt;option value=""&gt;-- Select match --&lt;/option&gt;' + opts;
      document.getElementById('scoreMatchId').innerHTML = html;
      document.getElementById('eventMatchId').innerHTML = html;
    }

    function renderMatchList(matches) {
      const list = document.getElementById('matchList');
      if (!matches.length) {
        list.innerHTML = '&lt;p style="color: var(--text-muted);"&gt;No matches yet. Create one above.&lt;/p&gt;';
        return;
      }
      list.innerHTML = matches.map(m =&gt; `
        &lt;div class="match-card" data-id="${m.id}"&gt;
          &lt;div class="match-info"&gt;
            &lt;div class="match-teams"&gt;\({m.homeTeam} vs \){m.awayTeam}&lt;/div&gt;
            &lt;div class="match-meta"&gt;\({m.league} • \){m.status} • ${m.minute}'&lt;/div&gt;
          &lt;/div&gt;
          &lt;div class="match-score"&gt;\({m.homeScore} - \){m.awayScore}&lt;/div&gt;
          &lt;div class="match-actions"&gt;
            &lt;button class="btn-secondary" onclick="quickScore('\({m.id}', \){m.homeScore}, ${m.awayScore})"&gt;Update&lt;/button&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      `).join('');
    }

    function quickScore(id, h, a) {
      document.getElementById('scoreMatchId').value = id;
      document.getElementById('homeScore').value = h;
      document.getElementById('awayScore').value = a;
    }

    async function loadMatches() {
      const matches = await fetchMatches();
      populateSelects(matches);
      renderMatchList(matches);
    }

    document.getElementById('createMatch').onsubmit = async (e) =&gt; {
      e.preventDefault();
      const body = {
        homeTeam: document.getElementById('homeTeam').value.trim(),
        awayTeam: document.getElementById('awayTeam').value.trim(),
        league: document.getElementById('league').value.trim() || 'Premier League',
        venue: document.getElementById('venue').value.trim() || 'TBD',
        kickoff: document.getElementById('kickoff').value
          ? new Date(document.getElementById('kickoff').value).toISOString()
          : new Date().toISOString(),
      };
      try {
        const res = await fetch(`${API}/matches`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body),
        });
        const data = await res.json();
        if (res.ok) {
          showToast('Match created!');
          document.getElementById('createMatch').reset();
          loadMatches();
        } else showToast(data.error || 'Failed', true);
      } catch (err) {
        showToast('Network error', true);
      }
    };

    document.getElementById('updateScore').onsubmit = async (e) =&gt; {
      e.preventDefault();
      const id = document.getElementById('scoreMatchId').value;
      const body = {
        homeScore: parseInt(document.getElementById('homeScore').value, 10),
        awayScore: parseInt(document.getElementById('awayScore').value, 10),
        minute: parseInt(document.getElementById('minute').value, 10) || undefined,
        status: document.getElementById('status').value,
      };
      try {
        const res = await fetch(`\({API}/matches/\){id}/score`, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body),
        });
        const data = await res.json();
        if (res.ok) {
          showToast('Score updated!');
          loadMatches();
        } else showToast(data.error || 'Failed', true);
      } catch (err) {
        showToast('Network error', true);
      }
    };

    document.getElementById('addEvent').onsubmit = async (e) =&gt; {
      e.preventDefault();
      const id = document.getElementById('eventMatchId').value;
      const body = {
        type: document.getElementById('eventType').value,
        team: document.getElementById('eventTeam').value.trim(),
        player: document.getElementById('eventPlayer').value.trim(),
        minute: parseInt(document.getElementById('eventMinute').value, 10) || undefined,
        description: document.getElementById('eventDesc').value.trim() || undefined,
      };
      try {
        const res = await fetch(`\({API}/matches/\){id}/events`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body),
        });
        const data = await res.json();
        if (res.ok) {
          showToast('Event added!');
          loadMatches();
        } else showToast(data.error || 'Failed', true);
      } catch (err) {
        showToast('Network error', true);
      }
    };

    loadMatches();
  &lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Explanation of each part:</p>
<ul>
<li><p><code>showToast</code>: updates the toast text and adds the show class to trigger the CSS animation. The isError parameter switches the toast to a red background for error messages. The setTimeout removes the show class after 3 seconds so the toast slides back out.</p>
</li>
<li><p><code>fetchMatches</code>: calls the GET /api/matches endpoint and returns the parsed JSON so the UI can display the latest data.</p>
</li>
<li><p><code>populateSelects</code>: builds option elements from the matches array and injects them into both dropdowns, so the same match list appears in the Update Score and Add Event forms.</p>
</li>
<li><p><code>renderMatchList</code>: either shows a placeholder when there are no matches or renders each match as a card with team names, scores, league, status, and an Update button.</p>
</li>
<li><p><code>quickScore</code>: pre-fills the Update Score form when you click the Update button on a match card, so you can adjust the score without re-selecting the match.</p>
</li>
<li><p><code>loadMatches</code>: fetches matches, populates the dropdowns, and renders the list. It runs on page load and after every successful create, update, or event submission.</p>
</li>
<li><p><code>onsubmit</code>: calls e.preventDefault() to stop the default form submission, builds a request body from the form values, sends a fetch request to the appropriate endpoint, and on success shows a toast and calls loadMatches to refresh the UI.</p>
</li>
</ul>
<h2 id="heading-how-to-build-the-live-viewer-interface">How to Build the Live Viewer Interface</h2>
<p>The viewer interface displays real-time match updates and connects to the Server-Sent Events endpoint so it can receive data the moment the server pushes it.</p>
<p>Unlike the admin page, the viewer is read-only: it shows live scores, match events, and status updates without requiring user input. The page uses a dark theme and a connection indicator so users can see whether the real-time stream is active.</p>
<p>Now create the file at <code>public/viewer.html</code>:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
  &lt;meta charset="UTF-8"&gt;
  &lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
  &lt;title&gt;Live Football Scores&lt;/title&gt;
  &lt;link rel="preconnect" href="https://fonts.googleapis.com"&gt;
  &lt;link rel="preconnect" href="https://fonts.gstatic.com" crossorigin&gt;
  &lt;link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&amp;display=swap" rel="stylesheet"&gt;
  &lt;style&gt;
    :root {
      --bg: #0a0e14;
      --surface: #131a24;
      --accent: #00d26a;
      --accent-glow: rgba(0, 210, 106, 0.3);
      --text: #e8edf2;
      --text-muted: #8b9aab;
      --border: #2d3a4d;
      --live: #ff4757;
    }
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: 'Outfit', sans-serif;
      background: var(--bg);
      color: var(--text);
      min-height: 100vh;
      padding: 2rem;
    }
    .container { max-width: 700px; margin: 0 auto; }
    header { text-align: center; margin-bottom: 2rem; }
    .status {
      display: inline-flex;
      align-items: center;
      gap: 0.5rem;
      font-size: 0.85rem;
      color: var(--text-muted);
    }
    .status-dot {
      width: 8px;
      height: 8px;
      border-radius: 50%;
      background: var(--text-muted);
      animation: pulse 2s infinite;
    }
    .status-dot.connected {
      background: var(--accent);
      box-shadow: 0 0 0 3px var(--accent-glow);
    }
    @keyframes pulse {
      0%, 100% { opacity: 1; }
      50% { opacity: 0.5; }
    }
    .match-card {
      background: var(--surface);
      border-radius: 16px;
      padding: 1.5rem;
      margin-bottom: 1rem;
      border: 1px solid var(--border);
      transition: border-color 0.2s, box-shadow 0.2s;
    }
    .match-card.live {
      border-color: var(--live);
      box-shadow: 0 0 0 1px rgba(255, 71, 87, 0.2);
    }
    .match-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      margin-bottom: 1rem;
    }
    .league { 
      font-size: 0.8rem; 
      color: var(--text-muted); 
      margin-bottom: 0.25rem; 
    }
    .match-teams {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 1rem;
      margin: 1rem 0;
    }
    .team { flex: 1; text-align: center; font-weight: 600; font-size: 1.1rem; }
    .team.home { text-align: left; }
    .team.away { text-align: right; }
    .score-box {
      display: flex;
      align-items: center;
      justify-content: center;
      min-width: 80px;
      gap: 0.5rem;
    }
    .score { 
      font-size: 2rem; 
      font-weight: 700; 
      color: var(--accent); 
     }
    .status-badge { 
      font-size: 0.7rem; 
      padding: 0.2rem 0.5rem; 
      border-radius: 4px; 
      font-weight: 600; 
     }
    .status-badge.live { background: var(--live); color: white; }
    .status-badge.finished { background: var(--border); color: var(--text-muted); }
    .status-badge.scheduled { background: var(--accent); color: var(--bg); }
    .match-meta { font-size: 0.85rem; color: var(--text-muted); margin-top: 0.5rem; }
    .events { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
    .events h4 { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.5rem; }
    .event { display: flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; padding: 0.35rem 0; border-bottom: 1px solid var(--border); }
    .event:last-child { border-bottom: none; }
    .event-icon { width: 24px; text-align: center; font-size: 1rem; }
    .event.goal .event-icon { color: var(--accent); }
    .event.yellow_card .event-icon { color: #ffd93d; }
    .event.red_card .event-icon { color: var(--live); }
    .feed { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); }
    .feed h3 { font-size: 1rem; margin-bottom: 1rem; color: var(--text-muted); }
    .feed-item { font-size: 0.85rem; padding: 0.5rem 0; color: var(--text-muted); border-bottom: 1px solid var(--border); }
    .feed-item:last-child { border-bottom: none; }
    .feed-item strong { color: var(--text); }
    .empty { text-align: center; padding: 3rem 2rem; color: var(--text-muted); }
    .empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: 0.5; }
  &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;div class="container"&gt;
    &lt;header&gt;
      &lt;h1&gt;⚽ Live Football Scores&lt;/h1&gt;
      &lt;div class="status" id="status"&gt;
        &lt;span class="status-dot" id="statusDot"&gt;&lt;/span&gt;
        &lt;span id="statusText"&gt;Connecting...&lt;/span&gt;
      &lt;/div&gt;
    &lt;/header&gt;

    &lt;div id="matches"&gt;&lt;/div&gt;

    &lt;div class="feed" id="feedSection"&gt;
      &lt;h3&gt;Live Feed&lt;/h3&gt;
      &lt;div id="feed"&gt;&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
</code></pre>
<p>Explanation of each part:</p>
<ul>
<li><p><code>header</code>: displays the page title and a connection indicator so users know whether the real-time stream is active.</p>
</li>
<li><p><code>status dot</code>: a small circle that pulses while disconnected and turns green with a glow when the SSE connection is established.</p>
</li>
<li><p><code>matches container</code>: the div with id="matches" is where match cards will be rendered dynamically by JavaScript as data arrives.</p>
</li>
<li><p><code>feed section</code>: shows a chronological list of live updates so users can see recent events at a glance.</p>
</li>
<li><p><code>CSS theme</code>: uses dark colors and custom properties so the look can be adjusted in one place. The live badge and border styles highlight matches that are in progress.</p>
</li>
<li><p><code>Server-Sent Events integration</code>: JavaScript (added in the next step) will connect to /api/events and update this page whenever new data arrives.</p>
</li>
</ul>
<p>The header shows the title and a connection status indicator. The <code>matches</code> div is the container for match cards, populated by JavaScript. The <code>feed</code> div displays the live feed of recent updates.</p>
<h3 id="heading-viewer-javascript-logic">Viewer JavaScript Logic</h3>
<p>The script powers the live viewer interface by maintaining an in-memory collection of matches and a feed of recent updates. It connects to the Server-Sent Events endpoint so data flows from the server to the browser in real time. When messages arrive, the page updates automatically to show the latest scores, events, and match details.</p>
<pre><code class="language-javascript">  &lt;script&gt;
    const API = '/api';
    const matches = new Map();
    const feed = [];
    const MAX_FEED = 20;

    const statusDot = document.getElementById('statusDot');
    const statusText = document.getElementById('statusText');
    const matchesEl = document.getElementById('matches');
    const feedEl = document.getElementById('feed');

    function setStatus(connected) {
      statusDot.classList.toggle('connected', connected);
      statusText.textContent = connected ? 'Live' : 'Reconnecting...';
    }

    function renderMatches() {
      const list = Array.from(matches.values()).sort(
        (a, b) =&gt; new Date(b.createdAt) - new Date(a.createdAt)
      );
      if (!list.length) {
        matchesEl.innerHTML = `
          &lt;div class="empty"&gt;
            &lt;div class="empty-icon"&gt;⚽&lt;/div&gt;
            &lt;p&gt;No matches yet. Updates will appear here in real-time.&lt;/p&gt;
          &lt;/div&gt;
        `;
        return;
      }
      matchesEl.innerHTML = list.map(m =&gt; `
        &lt;div class="match-card \({m.status === 'live' ? 'live' : ''}" data-id="\){m.id}"&gt;
          &lt;div class="match-header"&gt;
            &lt;div&gt;
              &lt;div class="league"&gt;${m.league}&lt;/div&gt;
              &lt;div class="match-meta"&gt;\({m.venue} • \){m.kickoff ? new Date(m.kickoff).toLocaleString() : ''}&lt;/div&gt;
            &lt;/div&gt;
            &lt;span class="status-badge \({m.status}"&gt;\){m.status}&lt;/span&gt;
          &lt;/div&gt;
          &lt;div class="match-teams"&gt;
            &lt;div class="team home"&gt;${m.homeTeam}&lt;/div&gt;
            &lt;div class="score-box"&gt;
              &lt;span class="score"&gt;${m.homeScore}&lt;/span&gt;
              &lt;span&gt;-&lt;/span&gt;
              &lt;span class="score"&gt;${m.awayScore}&lt;/span&gt;
            &lt;/div&gt;
            &lt;div class="team away"&gt;${m.awayTeam}&lt;/div&gt;
          &lt;/div&gt;
          \({m.minute ? `&lt;div class="match-meta"&gt;\){m.minute}'&lt;/div&gt;` : ''}
          ${m.events?.length ? `
            &lt;div class="events"&gt;
              &lt;h4&gt;Events&lt;/h4&gt;
              ${m.events.map(e =&gt; `
                &lt;div class="event ${e.type}"&gt;
                  &lt;span class="event-icon"&gt;${getEventIcon(e.type)}&lt;/span&gt;
                  &lt;span&gt;\({e.minute}' \){e.player} (\({e.team}) - \){e.description || e.type}&lt;/span&gt;
                &lt;/div&gt;
              `).join('')}
            &lt;/div&gt;
          ` : ''}
        &lt;/div&gt;
      `).join('');
    }

    function getEventIcon(type) {
      const icons = { goal: '⚽', yellow_card: '🟨', red_card: '🟥', substitution: '🔄', penalty: '⚽' };
      return icons[type] || '•';
    }

    function renderFeed() {
      const items = feed.slice(-MAX_FEED).reverse();
      feedEl.innerHTML = items.length
        ? items.map(f =&gt; `&lt;div class="feed-item"&gt;${f}&lt;/div&gt;`).join('')
        : '&lt;div class="feed-item"&gt;Waiting for updates...&lt;/div&gt;';
    }

    function addFeedItem(type, msg) {
      const time = new Date().toLocaleTimeString();
      feed.push(`&lt;strong&gt;\({time}&lt;/strong&gt; | \){msg}`);
      if (feed.length &gt; MAX_FEED) feed.shift();
      renderFeed();
    }

    function handleMessage(data) {
      if (data.match) {
        matches.set(data.match.id, data.match);
        renderMatches();
      }
      if (data.id &amp;&amp; data.homeTeam &amp;&amp; data.awayTeam) {
        matches.set(data.id, data);
        renderMatches();
      }
      if (data.type === 'match_created' &amp;&amp; data.match) {
        addFeedItem('match', `New match: \({data.match.homeTeam} vs \){data.match.awayTeam}`);
      }
      if (data.type === 'score_update') {
        const m = matches.get(data.matchId);
        if (m) {
          m.homeScore = data.homeScore;
          m.awayScore = data.awayScore;
          m.minute = data.minute;
          m.status = data.status;
          matches.set(data.matchId, m);
          addFeedItem('score', `\({m.homeTeam} \){data.homeScore}-\({data.awayScore} \){m.awayTeam} (${data.minute || '?'}')`);
          renderMatches();
        }
      }
      if (data.type === 'match_event' &amp;&amp; data.event) {
        const m = matches.get(data.matchId);
        if (m) {
          m.events = m.events || [];
          m.events.push(data.event);
          if (data.event.type === 'goal') {
            if (data.event.team === m.homeTeam) m.homeScore++;
            else if (data.event.team === m.awayTeam) m.awayScore++;
          }
          matches.set(data.matchId, m);
          addFeedItem('event', `\({data.event.type}: \){data.event.player} (\({data.event.team}) - \){data.event.minute}'`);
          renderMatches();
        }
      }
    }

    function handleSSEMessage(msg) {
      try {
        const data = JSON.parse(msg);
        if (data.topic &amp;&amp; /^sports\/football\/match\/([^/]+)$/.test(data.topic) &amp;&amp; data.id) {
          matches.set(data.id, data);
        }
        handleMessage(data);
      } catch (e) {}
    }

    function connectSSE() {
      const es = new EventSource(`${API}/events`);
      es.onopen = () =&gt; setStatus(true);
      es.onerror = () =&gt; {
        setStatus(false);
        es.close();
        setTimeout(connectSSE, 3000);
      };
      es.onmessage = (e) =&gt; handleSSEMessage(e.data);
    }

    async function loadInitial() {
      try {
        const res = await fetch(`${API}/matches`);
        const list = await res.json();
        list.forEach(m =&gt; matches.set(m.id, m));
        renderMatches();
      } catch (e) {
        matchesEl.innerHTML = '&lt;div class="empty"&gt;&lt;p&gt;Could not load matches. Is the server running?&lt;/p&gt;&lt;/div&gt;';
      }
    }

    loadInitial();
    connectSSE();
  &lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Explanation of each part:</p>
<ul>
<li><p>The <code>matches Map</code>: stores match objects keyed by ID so updates can be applied efficiently without searching arrays.</p>
</li>
<li><p>The <code>feed array</code>: keeps a small history of recent events (limited to MAX_FEED) so the live feed remains lightweight.</p>
</li>
<li><p><code>setStatus</code>: toggles the connected class on the status dot and updates the status text to “Live” or “Reconnecting…” so users know connection status.</p>
</li>
<li><p><code>renderMatches</code>: converts the Map to a sorted array (newest first). If there are no matches, it displays an empty state. Otherwise it renders cards showing league, venue, teams, scores, status badge, minute, and events.</p>
</li>
<li><p><code>getEventIcon</code>: returns an emoji for each event type so events are visually identifiable (goal, card, substitution, and so on).</p>
</li>
<li><p><code>renderFeed</code>: displays the live feed items or a placeholder message when no updates exist.</p>
</li>
<li><p><code>addFeedItem</code>: appends a timestamped message to the feed, keeps only the latest items, and re-renders the feed.</p>
</li>
<li><p><code>handleMessage</code>: processes incoming data. It updates the matches Map for full match objects, score updates, and match events. For score updates and goals it adjusts scores and adds feed items so the viewer reflects real-time changes.</p>
</li>
<li><p><code>handleSSEMessage</code>: parses the Server-Sent Events payload and forwards it to handleMessage. If the message contains a match topic and full match data, it stores it in the Map.</p>
</li>
<li><p><code>connectSSE</code>: creates an EventSource connection to /api/events. On open it marks the connection as live. On error it closes and retries after three seconds so transient network issues do not break the stream.</p>
</li>
<li><p><code>loadInitial</code>: fetches existing matches on page load so the viewer displays data even before real-time updates arrive.</p>
</li>
</ul>
<h2 id="heading-how-to-build-the-home-page">How to Build the Home Page</h2>
<p>The home page (<code>public/index.html</code>) is a simple landing page that links to the viewer and admin interfaces. It uses a centered card layout with two buttons. The primary button (green) goes to the viewer, and the secondary button (outlined) goes to the admin. The page uses the same dark theme and Outfit font for consistency. There is no JavaScript – it's purely static HTML and CSS.</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
  &lt;meta charset="UTF-8"&gt;
  &lt;meta name="viewport" content="width=device-width, initial-scale=1.0"&gt;
  &lt;title&gt;Football Scores - MQTT Real-Time&lt;/title&gt;
  &lt;link rel="preconnect" href="https://fonts.googleapis.com"&gt;
  &lt;link rel="preconnect" href="https://fonts.gstatic.com" crossorigin&gt;
  &lt;link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&amp;display=swap" rel="stylesheet"&gt;
  &lt;style&gt;
    :root {
      --bg: #0a0e14;
      --surface: #131a24;
      --accent: #00d26a;
      --text: #e8edf2;
      --text-muted: #8b9aab;
    }
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: 'Outfit', sans-serif;
      background: var(--bg);
      color: var(--text);
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 2rem;
    }
    .card {
      background: var(--surface);
      border-radius: 16px;
      padding: 2rem;
      max-width: 400px;
      text-align: center;
      border: 1px solid rgba(255,255,255,0.06);
    }
    h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
    p { color: var(--text-muted); font-size: 0.95rem; margin-bottom: 1.5rem; }
    .links { display: flex; flex-direction: column; gap: 0.75rem; }
    a {
      display: block;
      padding: 1rem 1.5rem;
      background: var(--accent);
      color: var(--bg);
      text-decoration: none;
      font-weight: 600;
      border-radius: 10px;
      transition: opacity 0.2s;
    }
    a:hover { opacity: 0.9; }
    a.secondary {
      background: transparent;
      color: var(--text);
      border: 1px solid rgba(255,255,255,0.15);
    }
  &lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
  &lt;div class="card"&gt;
    &lt;h1&gt;⚽ Football Scores&lt;/h1&gt;
    &lt;p&gt;Real-time updates via MQTT &amp; Mosquitto&lt;/p&gt;
    &lt;div class="links"&gt;
      &lt;a href="/viewer.html"&gt;View Live Scores&lt;/a&gt;
      &lt;a href="/admin.html" class="secondary"&gt;Admin - Upload Scores&lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>The Express server serves <code>index.html</code> when you visit the root URL (<code>http://localhost:3000/</code>) because the <code>express.static</code> middleware serves files from the <code>public</code> folder, and Express automatically serves <code>index.html</code> when the request path is <code>/</code>.</p>
<h2 id="heading-how-to-run-and-test-the-system">How to Run and Test the System</h2>
<p>Start the MQTT broker (Docker or local install).</p>
<p>Then start the Express server:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>Next, open <code>http://localhost:3000/admin.html</code> in the admin panel. Create a new match (for example, Manchester United vs Liverpool).</p>
<p>Open <code>http://localhost:3000/viewer.html</code> in another tab or window. Then update the score or add an event in the admin panel. The viewer should update within a second without refreshing.</p>
<h2 id="heading-how-to-extend-the-system-for-production">How to Extend the System for Production</h2>
<p>The current implementation uses in-memory storage. For production, you should:</p>
<ul>
<li><p><strong>Add a database</strong>: Store matches in PostgreSQL, MongoDB, or another database. Load matches on startup and persist every create, update, and event.</p>
</li>
<li><p><strong>Add authentication</strong>: Protect the admin routes with JSON Web Tokens (JWT) or session-based auth so only authorized users can upload scores.</p>
</li>
<li><p><strong>Add validation</strong>: Validate request bodies with a library such as Joi or Zod to prevent invalid data.</p>
</li>
<li><p><strong>Enable TLS</strong>: Use HTTPS for the Express server and secure WebSockets or MQTTS for the broker in production.</p>
</li>
<li><p><strong>Scale horizontally</strong>: If you run multiple server instances, each will have its own MQTT connection and SSE clients. The MQTT broker will deliver messages to all subscribers, so each instance will receive updates and forward them to its connected viewers.</p>
</li>
</ul>
<h2 id="heading-api-reference">API Reference</h2>
<p>For quick reference, here are the endpoints your server exposes:</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td>GET</td>
<td><code>/api/matches</code></td>
<td>Returns all matches as a JSON array</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/matches</code></td>
<td>Creates a new match. Body: <code>{ homeTeam, awayTeam, league?, venue?, kickoff? }</code></td>
</tr>
<tr>
<td>PATCH</td>
<td><code>/api/matches/:id/score</code></td>
<td>Updates a match score. Body: <code>{ homeScore?, awayScore?, minute?, status? }</code></td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/matches/:id/events</code></td>
<td>Adds an event. Body: <code>{ type?, team, player?, minute?, description? }</code></td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/events</code></td>
<td>Server-Sent Events stream for real-time updates</td>
</tr>
</tbody></table>
<p>The <code>status</code> field can be <code>scheduled</code>, <code>live</code>, <code>halftime</code>, or <code>finished</code>. The <code>type</code> field for events can be <code>goal</code>, <code>yellow_card</code>, <code>red_card</code>, <code>substitution</code>, or <code>penalty</code>.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p>You might come across various issues as you build this. Here are some common ones:</p>
<p><strong>The server cannot connect to the MQTT broker.</strong> Check that Mosquitto is running. If you use Docker, run <code>docker ps</code> to verify the container is up. If you use the public broker, ensure you have internet connectivity and that your firewall allows outbound connections on port 1883.</p>
<p><strong>The viewer does not update when you change scores.</strong> Open the browser developer tools and check the Network tab. The <code>/api/events</code> request should show a pending state (it stays open). If it fails or closes, check the server logs for errors. Ensure you are not behind a proxy that buffers or closes long-lived connections.</p>
<p><strong>Matches disappear when you restart the server.</strong> The current implementation stores matches in memory. Restarting the server clears the data. Add a database as described in the production section to persist matches across restarts.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a real-time football sports update system using MQTT, Mosquitto, and Express. You've learned how to:</p>
<ul>
<li><p>Connect an Express server to an MQTT broker</p>
</li>
<li><p>Publish match and score updates to MQTT topics</p>
</li>
<li><p>Subscribe to topics and forward messages to browsers via Server-Sent Events</p>
</li>
<li><p>Build an admin interface for creating matches and updating scores</p>
</li>
<li><p>Build a viewer interface that displays live updates without page refreshes</p>
</li>
</ul>
<p>The same pattern applies to other real-time systems: IoT dashboards, live notifications, collaborative editing, and more. MQTT gives you a reliable, scalable messaging layer, and Server-Sent Events gives you a simple way to push updates to web clients.</p>
<p>The code in this tutorial gives you a solid foundation. Try adding features such as match filtering by league, historical event logs, or push notifications to make the system your own.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Choose a Web Application Firewall for Web Security ]]>
                </title>
                <description>
                    <![CDATA[ If you run a website or web app, you’ve probably heard about firewalls. But there’s a special kind just for websites called a Web Application Firewall, or WAF.  Think of it like a bouncer at the door of your site, checking every visitor to make sure ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-choose-a-web-application-firewall-for-web-security/</link>
                <guid isPermaLink="false">685595d957b6666dfb68743f</guid>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #cybersecurity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ firewall ]]>
                    </category>
                
                    <category>
                        <![CDATA[ web application ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Fri, 20 Jun 2025 17:09:45 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1750439345651/1a6db323-b71f-4d0c-beb9-07833b838800.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you run a website or web app, you’ve probably heard about firewalls. But there’s a special kind just for websites called a Web Application Firewall, or WAF. </p>
<p>Think of it like a bouncer at the door of your site, checking every visitor to make sure they’re not trying anything shady before letting them through.</p>
<p>While regular firewalls protect your network, a WAF specifically filters traffic that targets your app. It looks for dangerous requests – like someone trying to inject bad code (SQL injection), trick your browser (XSS), or flood your server with fake users (bots). A good WAF stops these threats in real-time, long before they can cause damage.</p>
<p>Now, there are plenty of WAFs out there. Some are cloud-based and easy to plug in. Others give you more control and run on your own servers. </p>
<p>Let’s look at five great options, each offering different strengths depending on what you need. </p>
<h2 id="heading-cloudflare-wafhttpswwwcloudflarecomen-inapplication-servicesproductswaf"><a target="_blank" href="https://www.cloudflare.com/en-in/application-services/products/waf/">Cloudflare WAF</a></h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750308481873/cccd4962-dfd7-45cc-8096-c4bb8ab9d7dc.png" alt="Cloudflare WAF" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>Cloudflare has become almost a default for many small to mid-sized websites – and for good reason. Their WAF is fast to deploy and offers solid protection right out of the gate. It’s built into their global content delivery network (CDN), so not only do you get security, but your site loads faster too.</p>
<p>One big plus is that even the free plan gives you some basic protection. You can upgrade for more advanced features, like custom firewall rules, bot mitigation, and protection against zero-day threats (those new exploits that don’t have patches yet).</p>
<p>From e-commerce stores to popular hosting services, Cloudflare makes it really simple. You just point your domain to them, flip a few switches, and you’re protected. There’s not much to configure unless you want to get deep into the rules.</p>
<p>The only downside? If you need very specific filtering or want total control over how things are blocked, you might find it limiting without moving to their higher-tier plans.</p>
<h2 id="heading-imperva-wafhttpswwwimpervacomproductsweb-application-firewall-waf"><a target="_blank" href="https://www.imperva.com/products/web-application-firewall-waf/">Imperva WAF</a></h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750310485562/7d52256b-75ee-4a47-8ecf-52b2f44e1b07.png" alt="Imperva WAF" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>If Cloudflare is your plug-and-play option, Imperva is the full-blown enterprise solution. </p>
<p>This WAF is made for organizations that need more than just basic protection. It’s not just looking at requests and saying yes or no – it’s analyzing traffic patterns, understanding what’s normal, and alerting you when something looks off.</p>
<p>Imperva also helps with compliance. So if you’re in a regulated industry like finance, healthcare, or government, it can help you meet data protection rules and audit requirements.</p>
<p>You can use it in the cloud or install it on your own hardware, which is great if your company needs to keep things on-site.</p>
<p>Just know that it’s not as beginner-friendly as Cloudflare. There’s a learning curve, and pricing can get high depending on the features you use.</p>
<p>But if you’re running mission-critical web apps and need deep visibility into traffic and threats, Imperva is a strong contender.</p>
<h2 id="heading-safeline-wafhttpslysafepointcloudmdeggcz"><a target="_blank" href="https://ly.safepoint.cloud/mDEggcZ">SafeLine WAF</a></h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750310503191/2de54ca9-0524-441e-9d62-afe6e9f5582e.png" alt="Safeline WAF" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>Now let’s talk about something different – SafeLine. Unlike the big-name cloud platforms, SafeLine is a self-hosted WAF. That means you run it yourself, right alongside your web server.</p>
<p>Built on NGINX, one of the fastest and most popular web servers out there, SafeLine is designed to be lightweight but powerful. It has over 300,000 installations and more than 16,000 stars on <a target="_blank" href="https://github.com/chaitin/SafeLine">GitHub</a>. That’s a pretty big community for a security tool.</p>
<p>What makes it special is how it analyzes web traffic. SafeLine uses something called semantic detection. Instead of just looking for known attack signatures, it tries to understand what each request is trying to do.</p>
<p>That helps it block more threats and reduce false alarms. It can detect things like SQL injection, cross-site scripting, directory traversal, and even bad bots.</p>
<p>It also adds cool tricks like rate limiting, identify authentication, challenge pages for suspicious users, and even dynamic encryption of your site’s HTML and JavaScript to confuse attackers.</p>
<p>Of course, because it’s self-hosted, it’s not for everyone. You need to install it, configure it, and keep it updated yourself. But if you’re comfortable working with Linux or you want full control over your WAF, SafeLine is a fantastic choice – especially since it provides a free edition for personal use.</p>
<h2 id="heading-fortinet-fortiwebhttpswwwfortinetcomproductsweb-application-firewallfortiweb"><a target="_blank" href="https://www.fortinet.com/products/web-application-firewall/fortiweb">Fortinet FortiWeb</a></h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750310537875/424385b3-7f3c-4ff0-bc43-a386c679bd77.png" alt="Fortinet WAF" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>Fortinet is a name that’s been around in network security for a long time. Their WAF, FortiWeb, brings that enterprise-level muscle to web apps. </p>
<p>It combines traditional filtering with machine learning to spot weird behavior. So if someone starts sending strange requests your site’s never seen before, FortiWeb can recognize it and shut it down.</p>
<p>What sets FortiWeb apart is its deep integration with the rest of the Fortinet ecosystem. If you’re already using FortiGate firewalls or FortiAnalyzer tools, adding FortiWeb is a natural next step. Everything works together, giving you a full picture of your network and web security.</p>
<p>It’s powerful, but it’s also complex. Setting it up and maintaining it takes time and expertise. And like Imperva, this is a tool that shines in large organizations with experienced security teams.</p>
<p>If that’s your environment – and you want high-end features like API discovery, anomaly detection, and DDoS protection – it’s worth a close look.</p>
<h2 id="heading-f5-advanced-wafhttpswwwf5comproductsbig-ip-servicesadvanced-waf"><a target="_blank" href="https://www.f5.com/products/big-ip-services/advanced-waf">F5 Advanced WAF</a></h2>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1750310555919/7f9979fc-d6d1-4d35-8e61-6e4ee7f3fedf.jpeg" alt="F5 Advanced WAF" class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<p>Last on our list is F5’s Advanced WAF. This one’s also built for big players. </p>
<p>It’s part of the larger F5 BIG-IP platform, which handles traffic management, load balancing, and more. If you already use BIG-IP, adding the WAF module gives you strong security without needing extra infrastructure.</p>
<p>F5’s WAF offers advanced protection against bots, APIs, and credential stuffing (where attackers try to log in with stolen passwords). One unique feature is its partnership with Shape Security, which gives it extra tools to identify fake users and bot traffic.</p>
<p>You can deploy F5’s WAF in your data center, in the cloud, or at the edge. That flexibility makes it attractive for companies running complex, multi-cloud applications.</p>
<p>But like the other enterprise options here, F5 comes with complexity and cost. If you’re running a big operation and need fine-grained control and integration, it’s a solid choice.</p>
<h2 id="heading-which-one-should-you-choose">Which One Should You Choose?</h2>
<p>There’s no single best WAF for everyone. What works for a solo developer running a WordPress blog might not cut it for a multinational bank. So the best choice comes down to what matters most to you.</p>
<ul>
<li><p>If you want something fast and simple, with a free tier and global speed boosts, Cloudflare is hard to beat.</p>
</li>
<li><p>If your team needs compliance support, traffic analytics, and strong API protection, Imperva fits the bill.</p>
</li>
<li><p>For developers who like to build and tinker, SafeLine offers impressive protection and full control – without breaking the bank.</p>
</li>
<li><p>And for enterprises with existing Fortinet or F5 setups, it makes sense to stay in those ecosystems for seamless integration and the highest level of customization.</p>
</li>
</ul>
<h2 id="heading-summary">Summary</h2>
<p>No matter what you choose, the important part is having a WAF in place. It’s one of the best defenses against the constant stream of attacks targeting websites today. Whether it’s blocking a SQL injection, filtering out bad bots, or just keeping your error logs clean, a good WAF keeps your site running smoothly and safely.</p>
<p>Hope you enjoyed this article. You can <a target="_blank" href="https://manishshivanandhan.com/">learn more about me</a> or <a target="_blank" href="https://www.linkedin.com/in/manishmshiva/">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
