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
- Start with what you have. Don't redesign your entire process. Map your actual workflow, not an idealized one. Add columns for pain points you discover later.
- Enforce WIP limits ruthlessly. A WIP limit that's consistently ignored isn't a limit. Configure your tooling (digital boards, CI/CD gates) to physically prevent exceeding limits. The JavaScript board above demonstrates this principle.
- Pull, don't push. Developers should pull new work only when their current items are done or blocked. Managers should never assign work directly into an in-progress column.
- Swarm on blockers. When a card is blocked, the whole team should swarm to resolve it before pulling new work. This prevents WIP from silently growing behind blocked items.
- Review limits regularly. Hold a weekly "Kaizen" (improvement) session. Look at the CFD, discuss bottlenecks, and adjust WIP limits by small increments — never more than 20% at a time.
- Make policies explicit. Define clear "Definition of Ready" and "Definition of Done" for each column transition. For example, a card enters "Ready" only when it has acceptance criteria and a clear owner.
- Limit work per person, not just per column. Even if a column has capacity, an individual developer should rarely work on more than two items simultaneously. Personal WIP limits prevent thrashing.
Common Anti-Patterns to Avoid
- Ignoring WIP limits "just this once." Every exception erodes the discipline. If limits feel too tight, change them formally — don't bypass them ad hoc.
- Creating a "holding pen" column. Adding an unregulated buffer column (e.g., "Inbox 2") defeats the purpose. Every stage that precedes a constrained column needs its own limit.
- Measuring utilization instead of flow. High utilization (everyone 100% busy) creates queues. Kanban optimizes for flow and predictability, not busyness.
- Treating Kanban as just a board. The board is a tool. Kanban is the practice of visualizing, limiting WIP, measuring flow, and continuously improving. Without WIP limits, it's just a to-do list with columns.
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.