← Back to DevBytes

Kanban: Visualizing Work and Limiting WIP

Kanban: Visualizing Work and Limiting WIP

Kanban is a workflow management method originally developed by Toyota in the late 1940s to improve manufacturing efficiency. In software development, it has evolved into a powerful framework that helps teams visualize their work, manage flow, and continuously improve delivery. The two foundational practices are visualizing work on a board and limiting Work In Progress (WIP). This tutorial covers the core concepts, practical implementation, and includes runnable code examples to solidify your understanding.

What Kanban Is

At its heart, Kanban is a pull-based system. Work is pulled into the next stage only when capacity is available, rather than being pushed based on arbitrary deadlines. A Kanban board is the primary tool — a visual representation of work items moving through defined stages from "To Do" to "Done." Each stage has an explicit WIP limit that caps the number of items allowed simultaneously. When a column hits its limit, the team must finish existing work before pulling in new items. This exposes bottlenecks, reduces multitasking, and smooths out delivery flow.

Why Limiting WIP Matters

Unlimited WIP is the silent killer of developer productivity. Context switching between too many tasks creates cognitive overhead, delays, and burnout. Limiting WIP forces focus, accelerates cycle time (the time a task spends from start to finish), and makes blockers immediately visible. The relationship is captured by Little's Law from queueing theory:

Cycle Time = WIP / Throughput

Where:
  WIP        = Average number of items in progress
  Throughput = Average items completed per unit of time

If your team averages 4 items completed per week and has 20 items in progress, the predicted cycle time is 5 weeks. Cutting WIP to 8 drops cycle time to 2 weeks. This isn't just theory — it's measurable. The following Python simulation demonstrates the effect of WIP limits on cycle time.

import random
import statistics
from dataclasses import dataclass, field
from typing import List, Optional

@dataclass
class WorkItem:
    id: int
    effort_days: float  # How many days of active work needed
    arrival_day: float
    started_day: Optional[float] = None
    finished_day: Optional[float] = None

def simulate_kanban(wip_limit: int, items_count: int = 50, 
                    arrival_rate: float = 2.0, capacity_per_day: float = 1.0):
    """
    Simulate a single-stage Kanban system with a WIP limit.
    
    - wip_limit: Maximum items allowed in progress simultaneously
    - items_count: Total work items to process
    - arrival_rate: Average new items arriving per day (Poisson process)
    - capacity_per_day: Work units the team can complete per day
    """
    items: List[WorkItem] = []
    queue: List[WorkItem] = []  # Backlog / Ready column
    in_progress: List[WorkItem] = []
    completed: List[WorkItem] = []
    clock = 0.0
    next_item_id = 0
    
    # Pre-generate arrival times
    arrival_times = []
    t = 0.0
    while len(arrival_times) < items_count:
        interval = random.expovariate(1.0 / arrival_rate)
        t += interval
        arrival_times.append(t)
    
    # Generate work items with random effort between 0.5 and 5 days
    for i in range(items_count):
        items.append(WorkItem(
            id=i, 
            effort_days=random.uniform(0.5, 5.0),
            arrival_day=arrival_times[i]
        ))
    
    # Simulation loop
    while len(completed) < items_count:
        # Check for new arrivals at current clock time
        for item in items:
            if item.arrival_day <= clock and item.started_day is None \
               and item not in queue and item not in in_progress \
               and item not in completed:
                queue.append(item)
        
        # Pull work into in_progress if capacity exists
        while len(in_progress) < wip_limit and queue:
            item = queue.pop(0)
            item.started_day = clock
            in_progress.append(item)
        
        # Work on in_progress items (advance by capacity_per_day)
        if in_progress:
            # Distribute daily capacity evenly across in_progress items
            effort_per_item = capacity_per_day / len(in_progress)
            completed_this_tick = []
            for item in in_progress:
                item.effort_days -= effort_per_item
                if item.effort_days <= 0:
                    item.finished_day = clock
                    completed_this_tick.append(item)
                    completed.append(item)
            for item in completed_this_tick:
                in_progress.remove(item)
        
        clock += 1.0  # Advance one day
    
    # Calculate cycle times
    cycle_times = []
    for item in completed:
        if item.started_day is not None and item.finished_day is not None:
            cycle_times.append(item.finished_day - item.started_day)
    
    avg_cycle_time = statistics.mean(cycle_times) if cycle_times else 0
    max_cycle_time = max(cycle_times) if cycle_times else 0
    return avg_cycle_time, max_cycle_time, clock

# Run with different WIP limits
print("=== WIP Limit Simulation (50 items) ===\n")
for limit in [2, 5, 10, 50]:
    avg_ct, max_ct, total_days = simulate_kanban(wip_limit=limit, items_count=50)
    print(f"WIP Limit: {limit:3d} | Avg Cycle Time: {avg_ct:5.1f} days | "
          f"Max Cycle Time: {max_ct:5.1f} days | Total Sim Days: {total_days:5.1f}")

Running this simulation shows that as WIP limits tighten, average and maximum cycle times drop significantly, even though total calendar time may extend slightly. The trade-off is faster individual task completion at the cost of slightly lower overall throughput utilization — but the predictability gain is almost always worth it.

Building a Visual Kanban Board

A Kanban board doesn't require expensive tools. You can start with sticky notes on a wall or a simple digital board. Here's a complete, functional Kanban board built with HTML, CSS, and vanilla JavaScript that enforces WIP limits visually.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Kanban Board with WIP Limits</title>
<style>
  * { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: 'Segoe UI', sans-serif; background: #1a1a2e; 
         color: #eee; padding: 20px; }
  h1 { text-align: center; margin-bottom: 30px; color: #e94560; }
  .board { display: flex; gap: 20px; justify-content: center; 
           min-height: 70vh; }
  .column { background: #16213e; border-radius: 12px; padding: 15px;
            width: 280px; min-height: 400px;
            box-shadow: 0 4px 20px rgba(0,0,0,0.3); }
  .column-header { display: flex; justify-content: space-between; 
                   align-items: center; margin-bottom: 15px; }
  .column h2 { font-size: 1.2em; color: #0f3460; background: #e94560; 
               padding: 6px 12px; border-radius: 20px; }
  .wip-limit { font-size: 0.85em; color: #aaa; }
  .wip-limit.warning { color: #ff6b6b; font-weight: bold; }
  .cards { min-height: 100px; padding: 8px 0; }
  .card { background: #1f4287; padding: 12px 14px; margin-bottom: 10px;
          border-radius: 8px; cursor: grab; user-select: none;
          transition: transform 0.15s, box-shadow 0.15s;
          box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
  .card:hover { transform: translateY(-2px); 
                box-shadow: 0 6px 16px rgba(233,69,96,0.4); }
  .card.dragging { opacity: 0.5; transform: scale(0.95); cursor: grabbing; }
  .card .title { font-weight: 600; font-size: 1em; }
  .card .desc { font-size: 0.8em; color: #bcd; margin-top: 4px; }
  .card .id { font-size: 0.7em; color: #889; margin-top: 6px; }
  .add-card-btn { width: 100%; padding: 10px; background: transparent;
                  border: 2px dashed #555; color: #aaa; border-radius: 8px;
                  cursor: pointer; margin-top: 10px; transition: 0.2s; }
  .add-card-btn:hover { border-color: #e94560; color: #e94560; }
  .stats { text-align: center; margin-top: 30px; font-size: 0.9em; color: #888; }
  .column.drag-over { outline: 3px dashed #e94560; outline-offset: 4px; }

</head>
<body>

<h1>🚀 Kanban Board — Visualize & Limit WIP</h1>

<div class="board" id="board">
  <!-- Columns generated dynamically -->
</div>

<div class="stats" id="stats"></div>

<script>
// Column configuration with WIP limits
const COLUMNS = [
  { id: 'backlog', title: '📥 Backlog', wipLimit: Infinity, cards: [] },
  { id: 'ready',   title: '📋 Ready',   wipLimit: 8, cards: [] },
  { id: 'dev',     title: '💻 In Dev',  wipLimit: 3, cards: [] },
  { id: 'review',  title: '👀 Review',  wipLimit: 2, cards: [] },
  { id: 'done',    title: '✅ Done',    wipLimit: Infinity, cards: [] }
];

// Sample cards
const initialCards = [
  { id: 'TKT-101', title: 'Fix login bug', desc: 'OAuth token refresh fails' },
  { id: 'TKT-102', title: 'Add dark mode', desc: 'Implement theme toggle' },
  { id: 'TKT-103', title: 'Update API docs', desc: 'Swagger endpoint descriptions' },
  { id: 'TKT-104', title: 'Optimize DB queries', desc: 'N+1 query in user list' },
  { id: 'TKT-105', title: 'Write unit tests', desc: 'Coverage for auth module' },
  { id: 'TKT-106', title: 'Deploy pipeline fix', desc: 'CI failing on main branch' },
];

// Initialize: distribute cards across columns
COLUMNS[0].cards = [...initialCards];  // All start in Backlog
let draggedCardData = null;
let draggedFromColumnId = null;

function renderBoard() {
  const board = document.getElementById('board');
  board.innerHTML = '';
  
  COLUMNS.forEach((col) => {
    const columnEl = document.createElement('div');
    columnEl.className = 'column';
    columnEl.setAttribute('data-column-id', col.id);
    
    // Header
    const header = document.createElement('div');
    header.className = 'column-header';
    const titleEl = document.createElement('h2');
    titleEl.textContent = col.title;
    header.appendChild(titleEl);
    
    // WIP indicator
    const wipEl = document.createElement('span');
    wipEl.className = 'wip-limit';
    const currentCount = col.cards.length;
    if (col.wipLimit === Infinity) {
      wipEl.textContent = `${currentCount} items`;
    } else {
      wipEl.textContent = `${currentCount} / ${col.wipLimit}`;
      if (currentCount >= col.wipLimit) {
        wipEl.classList.add('warning');
      }
    }
    header.appendChild(wipEl);
    columnEl.appendChild(header);
    
    // Cards container
    const cardsContainer = document.createElement('div');
    cardsContainer.className = 'cards';
    
    col.cards.forEach((card, index) => {
      const cardEl = document.createElement('div');
      cardEl.className = 'card';
      cardEl.setAttribute('draggable', 'true');
      cardEl.dataset.cardIndex = index;
      cardEl.dataset.columnId = col.id;
      
      cardEl.innerHTML = `
        <div class="title">${escapeHtml(card.title)}</div>
        <div class="desc">${escapeHtml(card.desc)}</div>
        <div class="id">${escapeHtml(card.id)}</div>
      `;
      
      // Drag events
      cardEl.addEventListener('dragstart', (e) => {
        draggedCardData = card;
        draggedFromColumnId = col.id;
        cardEl.classList.add('dragging');
        e.dataTransfer.effectAllowed = 'move';
        e.dataTransfer.setData('text/plain', JSON.stringify(card));
      });
      
      cardEl.addEventListener('dragend', (e) => {
        cardEl.classList.remove('dragging');
        // Remove drag-over from all columns
        document.querySelectorAll('.column').forEach(c => 
          c.classList.remove('drag-over'));
      });
      
      cardsContainer.appendChild(cardEl);
    });
    
    columnEl.appendChild(cardsContainer);
    
    // Add card button
    const addBtn = document.createElement('button');
    addBtn.className = 'add-card-btn';
    addBtn.textContent = '+ Add Card';
    addBtn.addEventListener('click', () => {
      const newCard = {
        id: `TKT-${Date.now().toString().slice(-4)}`,
        title: 'New Task',
        desc: 'Click to edit description'
      };
      col.cards.push(newCard);
      renderBoard();
      updateStats();
    });
    columnEl.appendChild(addBtn);
    
    // Drop zone events
    columnEl.addEventListener('dragover', (e) => {
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';
      columnEl.classList.add('drag-over');
    });
    
    columnEl.addEventListener('dragleave', (e) => {
      // Only remove if truly leaving the column (not entering a child)
      if (!columnEl.contains(e.relatedTarget)) {
        columnEl.classList.remove('drag-over');
      }
    });
    
    columnEl.addEventListener('drop', (e) => {
      e.preventDefault();
      columnEl.classList.remove('drag-over');
      
      const targetColumnId = columnEl.getAttribute('data-column-id');
      const targetColumn = COLUMNS.find(c => c.id === targetColumnId);
      
      if (!draggedCardData || !draggedFromColumnId) return;
      if (draggedFromColumnId === targetColumnId) return;  // Same column, no-op
      
      // Check WIP limit on target column
      if (targetColumn.wipLimit !== Infinity && 
          targetColumn.cards.length >= targetColumn.wipLimit) {
        alert(`⚠️ WIP Limit Reached! "${targetColumn.title}" is at capacity ` +
              `(${targetColumn.wipLimit}). Finish existing work first.`);
        return;
      }
      
      // Find source column and remove card
      const sourceColumn = COLUMNS.find(c => c.id === draggedFromColumnId);
      const cardIndex = sourceColumn.cards.findIndex(
        c => c.id === draggedCardData.id);
      if (cardIndex !== -1) {
        const [removed] = sourceColumn.cards.splice(cardIndex, 1);
        targetColumn.cards.push(removed);
        renderBoard();
        updateStats();
      }
      
      draggedCardData = null;
      draggedFromColumnId = null;
    });
    
    board.appendChild(columnEl);
  });
}

function escapeHtml(str) {
  const div = document.createElement('div');
  div.textContent = str;
  return div.innerHTML;
}

function updateStats() {
  const stats = document.getElementById('stats');
  const totalCards = COLUMNS.reduce((sum, c) => sum + c.cards.length, 0);
  const doneCount = COLUMNS.find(c => c.id === 'done').cards.length;
  const inProgressCount = COLUMNS.filter(c => 
    ['ready', 'dev', 'review'].includes(c.id))
    .reduce((sum, c) => sum + c.cards.length, 0);
  
  stats.innerHTML = `
    📊 Total tasks: <strong>${totalCards}</strong> | 
    In progress: <strong>${inProgressCount}</strong> | 
    Done: <strong>${doneCount}</strong> |
    Flow efficiency: <strong>
    ${totalCards ? Math.round((doneCount / totalCards) * 100) : 0}%</strong>
  `;
}

// Initialize
renderBoard();
updateStats();
</script>
</body>
</html>

This board enforces WIP limits at the column level. When you attempt to drag a card into a column that has reached its WIP cap, an alert warns you and the drop is blocked. This mirrors real-world Kanban discipline: the board itself prevents overloading any stage. The Ready column has a higher limit (8) to buffer incoming work, while In Dev (3) and Review (2) are deliberately constrained to force focus.

How to Use Kanban in Your Development Workflow

Implementing Kanban doesn't require a massive process overhaul. Follow these incremental steps:

1. Map Your Current Workflow

Identify the stages work passes through. For a software team, a typical flow is: Backlog → Ready → In Progress → Code Review → Testing → Done. Start simple — you can always add columns later. Resist the urge to model every edge case upfront.

2. Establish WIP Limits

Set initial WIP limits based on your team size and historical data. A common heuristic for development columns is team members × 1.5. For a team of 4 developers, start with a limit of 6 in the development column. For review stages, use a lower multiplier (e.g., team size × 0.75) because review requires focused attention. Track and adjust limits every two weeks.

3. Visualize Blockers and Dependencies

Use visual signals on cards — colored tags, dots, or special markers — to indicate blocked items. When a card sits idle for more than a day, mark it as blocked and swarm on resolving the impediment. The following snippet shows a simple blocked-item tracking pattern you can integrate into project management tools.

// Example: Blocked item tracker integrated with a Kanban system
const blockedItems = new Map();

function markAsBlocked(cardId, reason, blockedBy) {
  blockedItems.set(cardId, {
    reason: reason,
    blockedBy: blockedBy,
    blockedSince: new Date().toISOString(),
    daysBlocked: 0
  });
}

function getBlockedDays(cardId) {
  const item = blockedItems.get(cardId);
  if (!item) return 0;
  const blockedDate = new Date(item.blockedSince);
  const now = new Date();
  return Math.floor((now - blockedDate) / (1000 * 60 * 60 * 24));
}

// Daily check: escalate items blocked > 3 days
function escalateStaleBlockers() {
  const staleThreshold = 3;
  blockedItems.forEach((item, cardId) => {
    const days = getBlockedDays(cardId);
    if (days > staleThreshold) {
      console.warn(`ESCALATION: Card ${cardId} blocked for ${days} days. ` +
                   `Reason: ${item.reason}. Blocked by: ${item.blockedBy}`);
    }
  });
}

// Usage
markAsBlocked('TKT-103', 'Waiting for API endpoint from platform team', 'platform-team');
escalateStaleBlockers();

4. Measure and Improve Flow

Track key metrics: Cycle Time (start to finish), Throughput (items completed per week), and WIP Aging (how long items sit in each column). Use cumulative flow diagrams to visualize bottlenecks. Here's a simple Python script to generate a cumulative flow diagram from your board data.

import matplotlib.pyplot as plt
from datetime import datetime, timedelta
import random

def generate_cfd_data(days=30):
    """
    Generate sample Cumulative Flow Diagram data.
    Returns dict with dates as keys and column counts as values.
    """
    dates = [datetime.now() - timedelta(days=i) for i in range(days, 0, -1)]
    dates.reverse()
    
    backlog = []
    in_progress = []
    done = []
    
    b, ip, dn = 40, 8, 0  # Starting counts
    for i in range(days):
        # Simulate flow: some items move from backlog to in_progress
        moved = random.randint(1, 3) if b > 0 else 0
        b -= moved
        ip += moved
        
        # Some items complete
        completed = random.randint(0, 2) if ip > 0 else 0
        ip -= completed
        dn += completed
        
        # New arrivals
        new_items = random.randint(1, 4)
        b += new_items
        
        backlog.append(b)
        in_progress.append(ip)
        done.append(dn)
    
    return dates, backlog, in_progress, done

def plot_cfd(dates, backlog, in_progress, done):
    """Plot a Cumulative Flow Diagram."""
    plt.figure(figsize=(12, 6))
    plt.stackplot(dates, backlog, in_progress, done,
                  labels=['Backlog', 'In Progress', 'Done'],
                  colors=['#f39c12', '#3498db', '#2ecc71'])
    plt.legend(loc='upper left')
    plt.title('Cumulative Flow Diagram', fontsize=16)
    plt.xlabel('Date')
    plt.ylabel('Number of Items')
    plt.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.savefig('cfd_output.png', dpi=120)
    print("Cumulative Flow Diagram saved as 'cfd_output.png'")
    plt.show()

# Generate and plot
dates, backlog, in_progress, done = generate_cfd_data(30)
plot_cfd(dates, backlog, in_progress, done)

# Analyze WIP area (the "band" between backlog top and done bottom)
avg_wip = sum(in_progress) / len(in_progress)
print(f"Average WIP during period: {avg_wip:.1f} items")
print("Wider bands = slower flow. Target: narrow, consistent bands.")

A widening band between Backlog and Done indicates growing WIP — a clear signal to revisit your WIP limits or investigate blockers.

Best Practices

Common Anti-Patterns to Avoid

Integrating Kanban with Agile Development

Kanban works harmoniously with Scrum and other Agile frameworks. Many teams run "Scrumban" — Scrum ceremonies (sprints, standups, retrospectives) combined with Kanban's continuous flow and WIP limits. The key adaptation is replacing the sprint backlog with a continuous backlog governed by WIP limits. Sprint planning becomes a replenishment meeting where the team pulls enough work to fill the Ready column up to its limit, rather than committing to a fixed scope.

# Example GitHub Projects workflow configuration (YAML)
# This defines a Kanban board with WIP limits enforced via automation

name: Kanban Board with WIP Limits
columns:
  - name: Backlog
    wip_limit: null  # unlimited
  - name: Ready for Dev
    wip_limit: 10
  - name: In Progress
    wip_limit: 6
  - name: Code Review
    wip_limit: 3
  - name: Done
    wip_limit: null

automations:
  # Auto-move items that meet "Definition of Ready" criteria
  - trigger: label_added
    label: ready-for-dev
    action: move_to_column
    column: Ready for Dev
  
  # Prevent moving items when target column exceeds WIP limit
  - trigger: column_change
    condition: target_column.item_count >= target_column.wip_limit
    action: block_move
    message: "WIP limit reached. Complete existing work before pulling new items."

This configuration illustrates how modern project management platforms can enforce Kanban policies programmatically. Whether you use GitHub Projects, Jira, Trello, or a physical board, the principles remain identical.

Conclusion

Kanban's power lies in its simplicity. By making work visible and imposing explicit WIP limits, teams gain clarity, reduce cycle times, and build a sustainable delivery cadence. The code examples in this tutorial — from the simulation proving Little's Law to the interactive HTML board and CFD plotting script — give you concrete tools to experiment with and adopt Kanban principles in your own development environment. Start by visualizing your current flow on a board, set modest WIP limits, measure the impact on cycle time, and iterate. The goal isn't perfect process; it's continuous, observable improvement — one card at a time.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles