Watching data for changes is a core task in modern applications. In a collaborative application same data gets modified by different stakeholders or users. In that scenario, it is very important to notify the owner of the record or the user who cares about that change. Watcher is The feature which enables user to keep an eye on the change programatically. In this article, we'll peel off that complex functionality and build a simple, custom watcher from the ground up.
Architecture Decisions
We will keep the implementation simple but extensible:
UI: React → lightweight, quick to build forms/buttons.
Backend: Express → simple routing for watch + update endpoints.
Database: SQLite → file-based, no setup, perfect for local dev.
Notifications: Console logs first → keep it easy to demo.
Async Layer: BullMQ + Redis → realistic queue-based processing without too much setup.
This stack lets us run locally in mins but can be upgraded later to Postgres, RabbitMQ, or Kafka if needed.
Architecture Diagram
- React UI → User clicks Watch.
- Express API → Handles request and updates DB.
- SQLite DB → Stores records and watcher subscriptions.
- BullMQ Queue → Stores notification jobs asynchronously.
- Worker → Pulls jobs and executes notifications.
- Notification Channel → Console logs, email, Slack, etc.
Database setup
We will create the simple table and insert a demo record.
CREATE TABLE records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
value TEXT
);
CREATE TABLE watchers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user TEXT,
record_id INTEGER,
FOREIGN KEY(record_id) REFERENCES records(id)
);
INSERT INTO records (name, value) VALUES (?, ?)", [
"Demo Record",
"Initial Value,
]
Backend implementation
server.js - Express server is running on port 4000. It adds a row in the watchers table when the watch button is clicked. Next time when the records table is updated, it checks for any watcher and puts a notification in the job queue.
app.post("/api/update/:id", (req, res) => {
const recordId = req.params.id;
const newValue = req.body.value;
db.updateRecord(recordId, newValue, (err) => {
if (err) return res.status(500).json({ error: err.message });
db.getWatchers(recordId, (err, watchers) => {
if (err) return res.status(500).json({ error: err.message });
watchers.forEach((w) => {
enqueueNotification(w.user, recordId, newValue);
});
res.json({ message: "Record updated and notifications queued." });
});
});
});
queue.js - This adds the notification in the bullMQ queue for processing by the worker
const { Queue } = require("bullmq");
const notificationQueue = new Queue("notifications", {
connection: { host: "127.0.0.1", port: 6379 },
});
function enqueueNotification(user, recordId, newValue) {
notificationQueue.add("notify", { user, recordId, newValue });
console.log(`Job queued for ${user} on record ${recordId}`);
}
Frontend implementation
App.js - Simple app for the watcher demo
import React, { useEffect, useState } from "react";
import { getRecord, watchRecord, updateRecord } from "./api";
function App() {
const [record, setRecord] = useState(null);
const [newValue, setNewValue] = useState("");
useEffect(() => {
async function load() {
const data = await getRecord(1);
setRecord(data);
}
load();
}, []);
const handleWatch = async () => {
const res = await watchRecord(1);
alert(res.message);
};
const handleUpdate = async () => {
if (!newValue) return alert("Enter a new value first!");
const res = await updateRecord(1, newValue);
alert(res.message);
const data = await getRecord(1);
setRecord(data);
setNewValue("");
};
if (!record) return <div>Loading...</div>;
return (
<div style={{ padding: "20px" }}>
<h2>Watcher Demo</h2>
<p><strong>Record:</strong> {record.name}</p>
<p><strong>Value:</strong> {record.value}</p>
<div style={{ marginTop: "20px" }}>
<button onClick={handleWatch} style={{ marginRight: "10px" }}>Watch</button>
<input value={newValue} onChange={e => setNewValue(e.target.value)} placeholder="New Value"/>
<button onClick={handleUpdate} style={{ marginLeft: "10px" }}>Update</button>
</div>
</div>
);
}
export default App;
api.js -
const API_BASE = "http://localhost:4000/api";
export async function getRecord(id) {
const res = await fetch(`${API_BASE}/record/${id}`);
return res.json();
}
export async function watchRecord(id, user = "demo-user") {
const res = await fetch(`${API_BASE}/watch/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user }),
});
return res.json();
}
export async function updateRecord(id, newValue) {
const res = await fetch(`${API_BASE}/update/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: newValue }),
});
return res.json();
}
Worker implementation
worker.js - This pulls the notifications from the queue to send to the right channel (console, email, slack etc)
const { Worker } = require("bullmq");
const worker = new Worker(
"notifications",
async (job) => {
const { user, recordId, newValue } = job.data;
console.log(`Notifying ${user}: Record ${recordId} changed to "${newValue}"`);
},
{
connection: { host: "127.0.0.1", port: 6379 },
}
);
worker.on("completed", (job) => console.log(`Job ${job.id} completed`));
worker.on("failed", (job, err) => console.error(`Job ${job.id} failed: ${err.message}`));
Code build, deploy and run server
Start Redis: docker run -d -p 6379:6379 redis
Start backend: npm install && npm start
Start worker: npm install && npm start
Start frontend: npm install && npm start
Test the application
- Clicking Watch
- Updating the value to see logs in the worker console
Frontend
Worker processed jobs
In this article we built a custom watcher system that lets users “watch” a record and get notified when it changes - all in a way that scales. This feature is one of the most essential feature from user standpoint to keep an eye on the record. We not only built a working demo but also learned a design pattern that is used in real-world systems like GitHub, Jira, Confluence etc.
Top comments (0)