Community Article
Community articles are authored by SitePoint Premium contributors. Content is screened before publication, and SitePoint reserves the right to moderate or remove articles that violate our guidelines. Views expressed are those of the authors and do not necessarily reflect those of SitePoint.
Building a Multi-Campus Communication Dashboard with Node.js and WebSockets
SASaifullah AdenwallaPublished inNode.js·
August 26, 2026
·Updated:August 26, 2026
The AI briefing for Developers
Stay up to date with AI tools, model releases, and developer workflows that matter.
Weekly. Free. One click to leave.
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
- Premium Results
- Publish articles on SitePoint
- Daily curated jobs
- Learning Paths
- Discounts to dev tools
7 Day Free Trial. Cancel Anytime.
A communication dashboard for one office is fairly straightforward.
A communication dashboard for an entire school district is a different engineering problem.
A district might have a central administration office, several schools, an admissions team, transportation staff, counselors, IT support, attendance departments, and employees who move between campuses.
Parents shouldn’t need to understand that organizational structure just to reach the right person.
From their perspective, the workflow should remain simple:
Choose a school↓Choose a department↓Start contact↓Reach the right teamBehind that simple interface, however, the application needs to solve several problems:
This is a good example of a feature where the user interface may be small while the architecture behind it deserves careful thought.
In this tutorial, we’ll design the foundation of a multi-campus communication dashboard using Node.js, Express and WebSockets.
We won’t attempt to recreate telephone infrastructure in JavaScript. Instead, we’ll build the application layer responsible for users, campuses, departments, sessions and real-time events, then define a clean boundary where a managed voice platform can take over.
Start With the Domain Model
Before thinking about WebSockets or phone APIs, model the organization.
A common mistake is starting with telephone numbers:
const numbers ={attendance:"+1...",admissions:"+1..."};This works until the district adds another campus.
Now there are two attendance offices.
Then three.
Eventually, routing logic begins leaking throughout the frontend.
A better model starts with the organization itself.
const campuses =[{id:"north",name:"North Campus"},{id:"south",name:"South Campus"}];Departments can belong to campuses:
const departments =[{id:"north-attendance",campusId:"north",type:"attendance",name:"Attendance Office"},{id:"south-attendance",campusId:"south",type:"attendance",name:"Attendance Office"},{id:"north-transport",campusId:"north",type:"transportation",name:"Transportation"}];const staff =[{id:"user-148",name:"Maria Lopez",departments:["north-attendance"]},{id:"user-271",name:"Daniel Reed",departments:["north-transport"]}];{"campusId":"north","department":"attendance"}Extension 203 belongs to North Campus attendance.That distinction becomes important as the organization changes.
Numbers, queues and providers are implementation details.
The domain model should describe what the school actually understands.
Build the API Around Intent
Let’s create a minimal Express application:
importexpressfrom"express";const app =express();app.use(express.json());app.listen(3000,()=>{console.log("Communication API running on port 3000");});The client needs a way to discover available campuses:
app.get("/api/campuses",async(req, res)=>{const campuses =await campusRepository.findAll();res.json(campuses);});Then departments for a campus:
app.get("/api/campuses/:campusId/departments",async(req, res)=>{const departments =await departmentRepository.findByCampus(req.params.campusId);res.json(departments);});Notice that the API doesn’t expose provider configuration.
{"id":"north-attendance","name":"Attendance Office"}{"providerQueueId":"queue_782612","externalNumber":"+1..."}Those details belong on the server.
Represent Communication as a Session
When somebody starts a call, create an application-level session before contacting the voice provider.
{id:"session-8831",campusId:"north",departmentId:"north-attendance",initiatedBy:"parent-918",status:"created",createdAt:"2026-08-26T08:24:12Z"}This session becomes the application’s
A basic endpoint might look like:
app.post("/api/communication-sessions",async(req, res)=>{const{campusId,departmentId}= req.body;const department =await departmentRepository.findOne({id: departmentId,campusId});if(!department){return res.status(404).json({error:"Department not found"});}const session =await sessionRepository.create({campusId,departmentId,status:"created",createdAt:newDate().toISOString()});res.status(201).json(session);});Why create an internal session?
Because provider connections are temporary.
A WebSocket can disconnect.
A phone call can end.
A user can reload the browser.
The communication event itself should still have a stable identity.
session-8831to reference in logs, events and support tools.
Model the Session as a State Machine
Avoid a collection of unrelated booleans:
{ringing:true,connected:false,failed:true,ended:false}Those properties can easily contradict each other.
constSESSION_STATES=["created","routing","ringing","connected","ended","failed"];const transitions ={created:["routing","failed"],routing:["ringing","failed"],ringing:["connected","ended","failed"],connected:["ended","failed"],ended:[],failed:[]};functioncanTransition(current,next){return transitions[current]?.includes(next)??false;}asyncfunctiontransitionSession(session,nextState){if(!canTransition(session.status,nextState)){thrownewError(`Invalid transition:`+`${session.status}→${nextState}`);}return sessionRepository.update(session.id,{status: nextState});}Now the state model itself documents expected behavior.
Add Real-Time Updates With WebSockets
HTTP works well for creating the session.
It becomes less convenient once the browser needs updates such as:
Routing call...Calling Attendance Office...ConnectedCall endedsetInterval(fetchCallStatus,1000);works, but it’s wasteful and introduces delay.
WebSockets are better suited to bidirectional real-time applications. SitePoint’s guide to using WebSockets with Node.js explores the underlying model in more detail.
npm install wsimport{WebSocketServer}from"ws";const wss =newWebSocketServer({port:8080});We’ll maintain subscriptions by session ID:
const subscriptions =newMap();wss.on("connection",socket=>{socket.on("message",raw=>{const message =JSON.parse(raw);if(message.type==="subscribe"){subscribe(message.sessionId,socket);}});});functionsubscribe(sessionId,socket){if(!subscriptions.has(sessionId)){subscriptions.set(sessionId,newSet());}subscriptions.get(sessionId).add(socket);}Now the server can publish state changes.
functionpublish(sessionId,event){const clients =subscriptions.get(sessionId);if(!clients){return;}const message =JSON.stringify(event);for(const socket of clients){if(socket.readyState===1){socket.send(message);}}}When the session starts ringing:
publish(session.id,{type:"session.status.changed",status:"ringing"});The browser can update immediately.
Build the Client Around Events
The frontend shouldn’t contain telephony logic.
It should respond to application events.
const socket =newWebSocket("wss://app.example.org/realtime");socket.addEventListener("open",()=>{socket.send(JSON.stringify({type:"subscribe",sessionId}));});socket.addEventListener("message",event=>{const message =JSON.parse(event.data);if(message.type==="session.status.changed"){renderStatus(message.status);}});A simple renderer might map states to human-readable messages:
functionrenderStatus(status){const messages ={created:"Preparing your call…",routing:"Finding the right team…",ringing:"Calling the office…",connected:"Connected",ended:"Call ended",failed:"We couldn't complete the call."};statusElement.textContent=messages[status]??"Updating…";}The browser understands application states.
It doesn’t understand provider states.
That is intentional.
Keep Campus Routing on the Server
Suppose North Campus attendance has three available staff members:
{departmentId:"north-attendance",staff:["user-148","user-212","user-295"]}South Campus has a completely different team.
A parent should never download this routing table and decide which staff member to call from JavaScript.
Connect me with North Campus Attendance.The server determines how.
asyncfunctionresolveDestination({campusId,departmentId}){const route =await routingRepository.findOne({campusId,departmentId});if(!route){thrownewError("No communication route configured");}return route;}{campusId:"north",departmentId:"north-attendance",strategy:"ring-group",destinationId:"attendance-north"}The frontend doesn’t need to change if that routing strategy later becomes:
IVRqueueafter-hours voicemailThe user intent has not changed.
Only infrastructure has.
The Web Application Shouldn’t Become the PBX
At this point, our application knows:
which communication session is active
what the application-level status is
It still doesn’t need to implement the public telephone network.
School communications frequently require capabilities such as department routing, IVR menus, ring groups, mobile and desktop access, centralized multi-campus administration, voicemail and external telephone numbers. Those are the kinds of responsibilities typically handled by a managed VoIP school phone system rather than rebuilt inside the education application’s Node.js code.
That gives us a much cleaner architecture:
Browser↓Education platform↓Communication API↓Routing layer↓Voice provider↓Telephone networkOur application owns the education workflow.
The telephony platform owns telephony infrastructure.
That boundary prevents business logic and vendor-specific code from becoming the same thing.
Hide the Provider Behind an Adapter
Avoid sprinkling provider SDK calls throughout route handlers.
app.post("/api/call",async(req, res)=>{await someProvider.calls.create({...});});someProvider.calls.end(...);and a worker calls another provider method.
Eventually, changing the integration requires changing half the application.
Instead, define the interface your application actually needs.
classVoiceProvider{asyncstartCall({destination,sessionId}){thrownewError("Not implemented");}asyncendCall({providerCallId}){thrownewError("Not implemented");}}Implement the provider-specific adapter:
classManagedVoiceProviderextendsVoiceProvider{constructor(client){super();this.client= client;}asyncstartCall({destination,sessionId}){returnthis.client.calls.create({destination,metadata:{sessionId}});}asyncendCall({providerCallId}){returnthis.client.calls.end(providerCallId);}}voiceProvider.startCall(...)Application↓VoiceProvider interface↓Provider adapter↓External serviceThat’s ordinary dependency inversion applied to communication infrastructure.
Normalize External Events
The provider needs a way to tell our application what happened.
Typically, this is asynchronous.
call createdcall ringingcall answeredcall completedcall failedA webhook endpoint might receive those updates:
app.post("/webhooks/voice",async(req, res)=>{const event =req.body;awaithandleVoiceEvent(event);res.sendStatus(204);});In production, verify the provider’s webhook authentication or signature before trusting the event.
Then normalize provider terminology.
functionnormalizeStatus(providerStatus){const statuses ={queued:"routing",ringing:"ringing",answered:"connected",completed:"ended",failed:"failed"};return statuses[providerStatus];}{"status":"answered"}{"status":"connected"}Why bother?
Because your frontend shouldn’t have to know whether one vendor calls the state:
answeredin-progressThose differences belong inside the adapter.
Push Provider Events Back to the Dashboard
Now the interesting pieces connect.
Suppose an office employee answers.
Staff answers phone↓Voice platform↓Webhook↓Node.js application↓Session updated↓WebSocket event↓Browser dashboardThe webhook handler can update the session:
asyncfunctionhandleVoiceEvent(providerEvent){const session =await sessionRepository.findByProviderCallId(providerEvent.callId);if(!session){return;}const status =normalizeStatus(providerEvent.status);if(!status){return;}const updated =awaittransitionSession(session,status);publish(session.id,{type:"session.status.changed",status:updated.status});}{"type":"session.status.changed","status":"connected"}and updates its interface.
The client doesn’t need to poll the provider.
It doesn’t even need to know which provider exists.
Authorization Needs More Than a Hidden Button
A district dashboard may contain several roles:
ParentTeacherOffice StaffCampus AdministratorDistrict AdministratorThose roles shouldn’t have identical access.
const permissions ={parent:["communication:start"],staff:["communication:start","communication:view-own-campus"],campusAdmin:["communication:start","communication:view-campus","routing:view-campus"],districtAdmin:["communication:start","communication:view-all","routing:manage"]};The frontend can hide unavailable controls for usability.
The server must enforce the permission.
functionrequirePermission(permission){return(req,res,next)=>{if(!req.user.permissions.includes(permission)){return res.sendStatus(403);}next();};}app.put("/api/routing/:id",requirePermission("routing:manage"),updateRouting);SitePoint’s recent tutorial on building a secure employee document portal with Node.js and Express explores the same broader principle: authentication establishes identity, but the server still needs re
Campus Scope Is Part of Authorization
A staff member being authenticated doesn’t mean they should automatically see data from every school.
req.user={id:"user-148",campusIds:["north"]};const session =await sessionRepository.findById(req.params.id);and then hope the frontend hides inappropriate sessions.
const session =await sessionRepository.findOne({id:req.params.id,campusId:{$in:req.user.campusIds}});Now a North Campus employee requesting a South Campus session simply doesn’t receive it.
This is a particularly important principle in multi-tenant and multi-location applications:
Scope the data query itself whenever practical.
Reconnection Should Restore State
WebSockets eventually disconnect.
A laptop sleeps.
A Wi-Fi access point changes.
A browser temporarily loses connectivity.
The application shouldn’t interpret every dropped WebSocket as:
The call ended.Those are separate events.
socket.addEventListener("open",()=>{socket.send(JSON.stringify({type:"session:subscribe",sessionId}));});The server should return current state:
{"type":"session:snapshot","sessionId":"session-8831","status":"connected"}Now the client can recover.
This is one reason the communication session exists independently of the WebSocket.
WebSocket disconnectedCall disconnectedKeeping those concepts separate prevents many subtle bugs.
Make Duplicate Events Safe
External systems retry webhooks.
Networks retry requests.
A completed event might arrive twice.
one event = one deliverySuppose the session is already:
endedand another completion webhook arrives.
The handler shouldn’t create a second outcome.
asyncfunctionmarkEnded(session){if(session.status==="ended"){return session;}return sessionRepository.update(session.id,{status:"ended",endedAt:newDate().toISOString()});}Where possible, store provider event IDs and enforce uniqueness:
CREATEUNIQUEINDEXvoice_event_id_uniqueON voice_events (provider_event_id);That gives duplicate delivery a predictable result.
Idempotency is especially useful at system boundaries where you don’t control the network behavior.
Build an Operational Dashboard From Events
Once communication sessions exist as structured data, useful operational views become possible.
{campusId:"north",departmentId:"north-attendance",status:"ended",startedAt:"2026-08-26T08:24:12Z",connectedAt:"2026-08-26T08:24:19Z",endedAt:"2026-08-26T08:28:02Z"}The application can calculate:
Time to answerSession durationFailed-session rateCalls by departmentPeak communication periodsCampus workloadDo this from normalized application data rather than forcing the dashboard to understand raw provider events.
functionsecondsBetween(start,end){returnMath.round((newDate(end)-newDate(start))/1000);}const answerTime =secondsBetween(session.startedAt,session.connectedAt);The important architectural point isn’t the chart.
It’s that analytics are built on your application’s stable model.
Avoid Logging More Than You Need
Communication applications can easily become overly enthusiastic about logs.
logger.info(req.body);against every endpoint.
That can accidentally copy personal or sensitive data into infrastructure that wasn’t designed to store it.
Prefer structured operational events:
logger.info({event:"communication.session.created",sessionId:session.id,campusId:session.campusId,departmentId:session.departmentId});logger.info({event:"communication.session.connected",sessionId:session.id,answerTimeMs:answerTimeMs});This gives developers useful observability without treating logs as a shadow copy of application data.
Build the User Interface Around Departments, Not Technology
SIP extensionQueue 918PSTN destinationRing groupAttendance OfficeTransportationAdmissionsSchool OfficeSimilarly, a staff member doesn’t necessarily need to know which provider is currently routing calls.
Good infrastructure abstraction should be reflected in the UX.
Technology should disappear behind domain language.
The underlying implementation might be:
WebSocket+Node.js+External VoIP provider+PSTNbut the user experience remains:
Contact North Campus AttendanceThat’s a useful measure of architecture quality.
If provider terminology leaks throughout the interface, the integration boundary probably isn’t clean enough.
Think in Layers
The final system can be viewed as a set of responsibilities:
Browser UI│├── selects campus├── selects department├── starts communication└── displays real-time statusWebSocket layer│├── subscribes to sessions└── delivers state changesNode.js application│├── authenticates users├── authorizes resources├── manages sessions├── resolves routing└── records application stateVoice adapter│├── translates application intent├── normalizes provider events└── isolates provider detailsManaged telephony│├── routes phone calls├── handles external numbers├── manages queues└── connects to telephone networksEach layer knows only what it needs.
That’s much easier to maintain than putting:
user management+campus routing+WebSockets+provider API calls+telephony stateinside one large controller.
Final Thoughts
Building a school communications dashboard isn’t primarily a WebSocket problem.
It isn’t primarily a VoIP problem either.
It’s an application architecture problem.
The most important decisions are the boundaries:
Campus ≠ phone numberDepartment ≠ extensionCommunication session ≠ WebSocketApplication state ≠ provider stateAuthorization ≠ hidden UIEducation workflow ≠ telephony infrastructureOnce those boundaries are clear, the implementation becomes easier to reason about.
Node.js owns the application’s communication model.
WebSockets keep the browser synchronized with that model.
Server-side authorization keeps campus boundaries enforceable.
A provider adapter prevents vendor-specific code from spreading through the application.
And managed voice infrastructure handles the specialized phone capabilities developers don’t need to recreate themselves.
The result is a communication feature that can grow from one campus to an entire district without forcing the frontend to understand how every call reaches its destination.


