amer.catic@projectvisit.org dagb@projectvisit.org +46 31 322 7207

Building a Simple Kanban Board with JavaScript and WebSockets

A Kanban board gives a team a shared view of work as it moves from an unstarted queue to completion. A physical wall with cards can be effective, but a browser-based board makes updates visible across offices, workshops and remote desks. Adding WebSockets means changes appear immediately without each user repeatedly refreshing the page.

This tutorial builds a small real-time Kanban application with JavaScript, Node.js and the ws WebSocket library. The board will have three columns, allow users to add cards, and broadcast every change to connected browsers. It is intentionally compact so the underlying flow of events remains easy to understand.

The example suits a research prototype, a student project or a lightweight internal tool. It can also support conversations about work-in-progress limits, bottlenecks and flow efficiency, subjects that connect closely with flow efficiency metrics. The same patterns can later be expanded for an Australian engineering team working between Sydney, Melbourne, Brisbane or regional sites.

Why a real-time board is useful

Kanban represents work as cards moving through a system. A simple workflow might contain “To do”, “In progress” and “Done” columns. Each card has a title, an identifier and a current status. The board does not need to prescribe how a team works; it makes the current system visible so the team can discuss delays and priorities.

WebSockets provide a persistent, two-way connection between a browser and a server. With a standard HTTP request, the browser asks for data and receives a response. With a WebSocket, the server can send an event as soon as another user changes the board. That behaviour is useful during a planning session when several people are editing the same visual workspace.

The server should remain the source of truth. Browsers display a copy of the current state and send commands such as “create card” or “move card”. The server validates those commands, updates its state and broadcasts the new board to every connected client. This simple rule prevents each browser from inventing a different version of the workflow.

Set up the JavaScript project

Create a directory and initialise a Node.js project:

mkdir realtime-kanban
cd realtime-kanban
npm init -y
npm install express ws
mkdir public

Express will serve the browser files, while ws will provide the WebSocket server. Add a file named server.js in the project root and an index.html file inside public. For a prototype, the board can keep data in memory. Restarting the server will clear the cards, which is acceptable for learning but unsuitable for production use.

The application will listen on port 3000. This is convenient for local development, including a workshop where participants connect from laptops on the same network. If the board is used across a distributed team, deploy it behind HTTPS and use secure WebSockets (wss://) so authentication and data are protected.

Use this initial server structure:

const path = require("path");
const express = require("express");
const http = require("http");
const { WebSocketServer } = require("ws");

const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });

app.use(express.static(path.join(__dirname, "public")));

let cards = [
  { id: 1, title: "Map current workflow", status: "todo" },
  { id: 2, title: "Test browser updates", status: "doing" }
];

let nextId = 3;

The HTTP server and WebSocket server share the same port. That arrangement avoids cross-origin complications during local development and gives the browser one address for both its page and its real-time connection.

Build the WebSocket server

Add helper functions that send JSON messages to one client or to every connected client. Each message includes a type, allowing the browser to decide what to do with it. The server broadcasts the complete board after every accepted change, which is less efficient than sending small patches but much easier to reason about.

function sendBoard(socket) {
  socket.send(JSON.stringify({
    type: "board",
    cards
  }));
}

function broadcastBoard() {
  const message = JSON.stringify({ type: "board", cards });

  wss.clients.forEach((client) => {
    if (client.readyState === 1) {
      client.send(message);
    }
  });
}

wss.on("connection", (socket) => {
  sendBoard(socket);

  socket.on("message", (rawMessage) => {
    let message;

    try {
      message = JSON.parse(rawMessage);
    } catch {
      socket.send(JSON.stringify({
        type: "error",
        message: "Invalid JSON message"
      }));
      return;
    }

    if (message.type === "add") {
      const title = String(message.title || "").trim();

      if (!title || title.length > 120) {
        socket.send(JSON.stringify({
          type: "error",
          message: "A card title is required and must be under 120 characters"
        }));
        return;
      }

      cards.push({
        id: nextId++,
        title,
        status: "todo"
      });

      broadcastBoard();
    }

    if (message.type === "move") {
      const allowedStatuses = ["todo", "doing", "done"];
      const card = cards.find((item) => item.id === Number(message.id));

      if (card && allowedStatuses.includes(message.status)) {
        card.status = message.status;
        broadcastBoard();
      }
    }
  });
});

server.listen(3000, () => {
  console.log("Kanban board running at http://localhost:3000");
});

The validation is deliberately performed on the server rather than trusting the browser. A user can modify browser code or send a hand-written WebSocket message, so the server must check titles, identifiers and permitted columns. In a larger system, add user identity, access controls, audit events and persistent storage such as PostgreSQL.

Start the application with node server.js, then open http://localhost:3000 in two browser windows. When the client is complete, an update made in one window will appear in the other.

Create the browser interface

The page needs a form for new cards and three visual columns. Each card contains a title and buttons for moving it forward or backward. Using buttons rather than drag and drop keeps the first version accessible and reduces the amount of client-side state.

Place this markup in public/index.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Realtime Kanban</title>
  <style>
    body { font-family: sans-serif; max-width: 1100px; margin: 2rem auto; }
    form { display: flex; gap: .5rem; margin-bottom: 1.5rem; }
    input { flex: 1; padding: .7rem; }
    button { padding: .6rem .8rem; cursor: pointer; }
    .board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
    .column { background: #f1f3f5; padding: 1rem; min-height: 230px; }
    .card { background: white; margin: .7rem 0; padding: .8rem; box-shadow: 0 1px 3px #bbb; }
    .actions { display: flex; gap: .4rem; margin-top: .7rem; }
    @media (max-width: 700px) { .board { grid-template-columns: 1fr; } }
  </style>
</head>
<body>
  <h1>Team Kanban</h1>

  <form id="card-form">
    <input id="card-title" maxlength="120" placeholder="Describe a piece of work" required>
    <button type="submit">Add card</button>
  </form>

  <main class="board">
    <section class="column"><h2>To do</h2><div id="todo"></div></section>
    <section class="column"><h2>In progress</h2><div id="doing"></div></section>
    <section class="column"><h2>Done</h2><div id="done"></div></section>
  </main>

  <script src="client.js"></script>
</body>
</html>

Create public/client.js to open the connection and render the board. The browser uses location.host, so the same code works when the application is moved from localhost to a hosted environment.

const protocol = location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${protocol}://${location.host}`);

const columns = {
  todo: document.querySelector("#todo"),
  doing: document.querySelector("#doing"),
  done: document.querySelector("#done")
};

function send(message) {
  if (socket.readyState === WebSocket.OPEN) {
    socket.send(JSON.stringify(message));
  }
}

function render(cards) {
  Object.values(columns).forEach((column) => {
    column.replaceChildren();
  });

  cards.forEach((card) => {
    const element = document.createElement("article");
    element.className = "card";

    const title = document.createElement("div");
    title.textContent = card.title;
    element.appendChild(title);

    const actions = document.createElement("div");
    actions.className = "actions";

    const previous = { doing: "todo", done: "doing" }[card.status];
    const next = { todo: "doing", doing: "done" }[card.status];

    if (previous) {
      const button = document.createElement("button");
      button.textContent = "Back";
      button.onclick = () => send({
        type: "move",
        id: card.id,
        status: previous
      });
      actions.appendChild(button);
    }

    if (next) {
      const button = document.createElement("button");
      button.textContent = "Forward";
      button.onclick = () => send({
        type: "move",
        id: card.id,
        status: next
      });
      actions.appendChild(button);
    }

    element.appendChild(actions);
    columns[card.status].appendChild(element);
  });
}

socket.addEventListener("message", (event) => {
  const message = JSON.parse(event.data);

  if (message.type === "board") render(message.cards);
  if (message.type === "error") alert(message.message);
});

document.querySelector("#card-form").addEventListener("submit", (event) => {
  event.preventDefault();

  const input = document.querySelector("#card-title");
  send({ type: "add", title: input.value });
  input.value = "";
});

textContent is used instead of assigning card titles to innerHTML. That small decision prevents a submitted title from being interpreted as HTML or script. The browser also redraws the columns from the server’s current list, so all connected users receive the same ordering and status.

Test synchronisation and workflow rules

Run the server, open two tabs and add a card in one of them. The card should appear in both tabs. Move it forward, then test the Back button. Open the browser developer tools to inspect the WebSocket connection and any malformed messages. Testing with two clients reveals problems that are invisible when an application is used in a single window.

A useful next step is to make the workflow reflect the team’s actual process. A product team might use “Ready”, “Building”, “Review” and “Released”; a maintenance group might track “Reported”, “Diagnosing”, “Scheduled” and “Resolved”. Keep the number of states small enough that the board remains readable during a stand-up or visual planning session.

The server currently accepts every transition. You can enforce rules by checking the current status before changing it, or by adding a wipLimit to a column. For example, the server could reject a move into “doing” when that column already contains three cards. Such limits turn the board from a passive task list into a prompt to discuss blocked work and system capacity.

For teams working across Australian time zones, record event timestamps in UTC and display them in the viewer’s local time. A Sydney and Melbourne team will usually share a time zone, while Brisbane does not observe daylight saving. Clear timestamps avoid confusion when work is handed between offices or reviewed after hours.

Improve reliability and practical use

An in-memory array is useful for a tutorial, but a real service needs durable storage. Replace the array with a database table containing an ID, title, status, position, creator and timestamps. Save each command in a transaction, then broadcast the resulting state. If two users update the same card nearly simultaneously, the server should define a clear last-write or version-checking policy.

Reconnection is another important browser concern. A laptop may sleep, a home NBN connection may briefly drop, or a corporate network may close an idle socket. Add a close handler that retries with increasing delays. When the connection returns, the server sends a fresh board, restoring the client without requiring a full page reload.

Security should be addressed before exposing the board to the public internet. Serve it over HTTPS, authenticate the WebSocket handshake, validate message sizes, rate-limit commands and escape all user-generated content. A board used by an engineering supplier or a large manufacturer may contain confidential delivery information, so access control and audit logs matter as much as the interface.

The prototype can become a useful research instrument when it records transitions rather than only the latest state. Event history allows a team to calculate lead time, cycle time, blocked duration and throughput. It also creates evidence for experiments with visual management and digital planning. The Project Visit research site provides relevant context for work connecting digital visualisation, lean processes and industry collaboration.

Practical recommendations for extending the prototype

A small board is easiest to maintain when its rules are explicit. Before adding drag-and-drop, notifications or elaborate dashboards, confirm that the basic event flow is reliable: a user sends a command, the server validates it, the state changes once, and every client receives the same result.

A production version may add presence indicators, comments, search, swimlanes and role-based permissions. Those features should support the team’s workflow rather than make the board visually busy. A concise board that accurately reflects work is more valuable than a sophisticated interface that requires constant maintenance.

The practical takeaway is to start with three columns, one trusted server state and a small set of validated WebSocket events, then use observed flow data to decide which capability deserves to be added next.