← Back to tech insights

February 19, 2026 · 10 min

Lessons from Deploying Real-Time Task Management Systems

Building TaskNest pushed me into the real-time web — WebSockets, conflict resolution, presence indicators, and optimistic UI. Here are the hard lessons I learned.

Lessons from Deploying Real-Time Task Management Systems

TL;DR: Real-time collaboration feels magical when it works. Getting it to that state is significantly harder than building a regular CRUD app. Here's everything I learned building TaskNest — a real-time team task management system.


Why Real-Time Is Different

In a standard CRUD app, the request-response cycle is atomic: user clicks, data saves, UI updates. Done.

Real-time breaks this model entirely:

  • Multiple users edit the same data simultaneously
  • Changes must propagate to all connected clients instantly
  • Network drops and reconnects must be handled gracefully
  • Optimistic UI can conflict with server state

These are solvable problems — but they require deliberate architecture.


The Real-Time Stack

For TaskNest, I chose:

| Need | Technology | Reason | |------|-----------|--------| | Real-time transport | WebSocket (Socket.io) | Bidirectional, rooms, reconnect built-in | | Pub/Sub | Redis | Fast, supports multiple server instances | | Task state | PostgreSQL | ACID, persistent source of truth | | Optimistic updates | React + Zustand | Instant UI feedback |


Architecture: The Event Flow

Client A changes task title
         │
         ▼
   Socket.io Client
         │ emit('task:update', {...})
         ▼
   Socket.io Server
         │
    ┌────┴────┐
    │         │
    ▼         ▼
PostgreSQL  Redis Pub/Sub
(persist)   (broadcast)
               │
               ▼
         All servers receive
               │
         ┌─────┴─────┐
         ▼           ▼
    Client B     Client C
    (same room)  (same room)
    updates UI   updates UI

Key insight: Redis is the glue between server instances. Without it, WebSocket events only reach clients connected to the same server process — which breaks the moment you scale horizontally.


Setting Up Socket.io with Redis Adapter

// server/socket.ts
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

const io = new Server(httpServer, {
  cors: { origin: process.env.FRONTEND_URL, credentials: true },
  adapter: createAdapter(pubClient, subClient),
});

// All Socket.io events now sync across server instances via Redis

Room-Based Isolation

Each project gets its own Socket.io "room". Events only propagate within the room:

io.on('connection', (socket) => {
  // Join a project's room
  socket.on('project:join', async ({ projectId, userId }) => {
    // Verify user has access to this project
    const hasAccess = await checkProjectAccess(userId, projectId);
    if (!hasAccess) return socket.disconnect();
    
    socket.join(`project:${projectId}`);
    
    // Notify others of presence
    socket.to(`project:${projectId}`).emit('user:joined', { userId });
  });

  // Handle task updates
  socket.on('task:update', async (data) => {
    const { taskId, projectId, changes, clientTimestamp } = data;
    
    // Persist to DB
    const updatedTask = await updateTask(taskId, changes);
    
    // Broadcast to all others in the room
    socket.to(`project:${projectId}`).emit('task:updated', {
      taskId,
      changes: updatedTask,
      updatedBy: socket.data.userId,
      serverTimestamp: Date.now(),
    });
  });
});

Optimistic UI: The Double-Edged Sword

Optimistic UI makes the app feel instant. You update the local state immediately, then sync with the server asynchronously. But it can go wrong:

The Problem: Conflicting Updates

  1. User A moves task to "Done" → optimistic UI updates immediately
  2. User B deletes the same task 200ms later
  3. Server processes B's delete first
  4. Server rejects A's update (task doesn't exist)
  5. A's UI is now in a broken state

My Solution: Last-Write-Wins with Conflict Toast

For TaskNest, I implemented a simple LWW strategy with user notification:

// Client-side conflict handling
socket.on('task:conflict', ({ taskId, serverState, yourChange }) => {
  // Revert optimistic update
  taskStore.revert(taskId, serverState);
  
  // Notify user
  toast.warning(
    `Your change to "${serverState.title}" conflicted with another user's edit. The latest version has been restored.`,
    { duration: 5000 }
  );
});

// Zustand store with optimistic rollback
const useTaskStore = create<TaskStore>((set, get) => ({
  tasks: {},
  optimisticPatches: {},
  
  updateTaskOptimistic: (taskId, changes) => {
    // Store original for rollback
    const original = get().tasks[taskId];
    set(state => ({
      optimisticPatches: { ...state.optimisticPatches, [taskId]: original },
      tasks: { ...state.tasks, [taskId]: { ...original, ...changes } },
    }));
  },
  
  revert: (taskId, serverState) => {
    set(state => {
      const { [taskId]: _, ...patches } = state.optimisticPatches;
      return {
        optimisticPatches: patches,
        tasks: { ...state.tasks, [taskId]: serverState },
      };
    });
  },
}));

Presence Indicators

Showing who's online and what they're working on adds significant UX value:

// Track cursor/selection per user
socket.on('user:focusing', ({ taskId, userId }) => {
  socket.to(`project:${projectId}`).emit('presence:update', {
    userId,
    action: 'focusing',
    taskId,
  });
});

// React component for presence rings
function TaskPresence({ taskId }: { taskId: string }) {
  const focusedUsers = usePresenceStore(state => 
    state.getTaskFocusers(taskId)
  );
  
  return (
    <div className="flex -space-x-2">
      {focusedUsers.map(user => (
        <Avatar
          key={user.id}
          src={user.avatar}
          title={`${user.name} is viewing this`}
          className="ring-2 ring-white w-7 h-7"
        />
      ))}
    </div>
  );
}

Reconnection Strategy

Network drops happen. Socket.io handles reconnection automatically, but you need to handle the state gap:

socket.on('reconnect', async () => {
  // Re-join rooms
  await socket.emit('project:join', { projectId: currentProject });
  
  // Fetch any missed events since last connection
  const missedEvents = await fetch(
    `/api/projects/${currentProject}/events?since=${lastEventTimestamp}`
  );
  
  const events = await missedEvents.json();
  
  // Replay missed events in order
  for (const event of events) {
    applyEvent(event);
  }
  
  lastEventTimestamp = Date.now();
});

This "catch-up on reconnect" pattern is essential for data consistency.


Performance Lessons

Debounce Rapid Events

Typing in a task description triggers many events. Debounce before emitting:

const debouncedEmit = useMemo(
  () => debounce((changes) => socket.emit('task:update', changes), 300),
  [socket]
);

Don't Send Full Objects

Instead of sending the entire task object on every update, send only the diff:

// Bad
socket.emit('task:update', { ...fullTask, title: newTitle });

// Good  
socket.emit('task:update', { taskId, changes: { title: newTitle } });

Limit Room Size

In large teams (100+ users), fan-out becomes expensive. Consider paginating presence or using hierarchical rooms (team → project → task).


Deployment Considerations

  • Sticky sessions needed? — Only if you're NOT using Redis adapter. With Redis, any server can handle any client.
  • Vercel doesn't support WebSockets — I deployed the Socket.io server on Railway instead.
  • Health checks — Monitor Redis connection health separately from HTTP; a Redis drop silently breaks all real-time features.

The Results

After launching TaskNest with these patterns:

  • Updates propagate to all clients in < 50ms on average
  • Zero data loss events in the first 3 months
  • Reconnection works seamlessly across mobile network switches

Explore TaskNest: GitHub | Portfolio