The Motivation
I've built plenty of CRUD apps. REST API, database, React frontend, maybe some polling or a WebSocket layer for "real-time" updates. The setup is familiar but repetitive: define a schema, write resolvers, build API routes, manage client-side caching, handle WebSocket reconnection logic, deal with stale data...
When I discovered Convex, the pitch was compelling: define your schema and functions, and every \useQuery\ becomes a live subscription that automatically re-renders when data changes. No pub/sub, no WebSocket code, no cache invalidation. I wanted to test that claim by building something complex enough to stress-test it.
The Project: TaskFlow
TaskFlow is a collaborative task management app with teams, task assignments, comments, and real-time notifications. The stack:
- Next.js 15 with the App Router and TypeScript
- Convex for the database, serverless functions, and real-time sync
- Convex Auth for authentication (email/password with GitHub as a provider)
- Tailwind CSS with Radix UI primitives for the component library
The Service Architecture
This is where things got interesting. I organized the Convex backend as a microservice-style architecture - not separate deployed services, but logically isolated modules with clear boundaries:
- \
taskService.ts\- CRUD, status transitions, filtering, enrichment - \
userService.ts\- profiles, email uniqueness validation, dependency checks - \
teamService.ts\- team management, member roles, cascading deletes - \
commentService.ts\- threaded comments per task - \
notificationService.ts\- alerts for assignments, completions, and comments - \
eventBus.ts\- typed event definitions for cross-service communication
Each service exports its own types (\TaskFilters\, \CreateTeamInput\, etc.) and private helper functions. Public-facing files like \convex/tasks.ts\ re-export as a facade, giving the frontend a clean API surface like \api.tasks.getTasks\.
Real-Time: It Just Works
The moment I ran \useQuery(api.tasks.getTasksByTeam, {})\ and saw the task list update in another browser tab within milliseconds of a mutation - without any code on my part - I was sold.
In a traditional stack, achieving this requires:
- A REST or GraphQL endpoint
- A WebSocket server (Socket.IO, Pusher, etc.)
- Client-side state management with cache invalidation
- Reconnection and retry logic
- Conflict resolution for concurrent edits
Convex collapses all five into a single reactive query. The \useQuery\ hook subscribes to the underlying data; when any mutation touches the relevant tables, the query re-evaluates server-side and pushes the new result to every connected client.
Cross-Service Communication: Inline Events
Since everything runs on Convex's serverless functions, I couldn't use RabbitMQ or a traditional event bus. Instead, cross-service communication happens directly within mutations:
When a task is assigned to someone, the \taskService.create\ mutation inserts a notification inline - if the assignee isn't the creator, it inserts into the \notifications\ table with type \task_assigned\. The notification service's \useQuery\ picks it up instantly. Similarly, when a comment is added, the mutation checks if the task's assignee differs from the comment author and creates a \comment_added\ notification.
This is simpler than a proper event bus, but it couples services at the mutation level. For a production system at scale I'd want true async events, but for this project's scope the direct approach works cleanly.
The Schema: Five Tables, Ten Indexes
The Convex schema defines five tables with carefully chosen indexes:
- \
users\- name, email, role (admin/member), indexed by email - \
teams\- name, description, owner, indexed by owner - \
teamMembers\- junction table with role, indexed by team, by user, and by team+user (composite) - \
tasks\- title, status, priority, assignee, creator, team, due date - indexed five ways for the various query patterns - \
comments\- task reference, author, content, indexed by task and author - \
notifications\- user, type, title, message, read flag - indexed by user and by user+read (for unread count)
The composite index on \teamMembers(teamId, userId)\ is critical for the "is this user already a member?" check in \addMember\. Without it, every member addition would scan the entire table.
Business Rules I'm Proud Of
Cascading deletes with cross-service cleanup: Deleting a team removes all its members, then all its tasks, then the team itself. Deleting a user first checks for created tasks (throwing if any exist), then removes all team memberships. These aren't database-level cascades - they're explicit application logic, which makes the rules visible and testable.
Email uniqueness across mutations: The \validateEmailUnique\ helper queries the \by_email\ index before any user creation or update. If the email is taken (by a different user), it throws. The exclusion parameter handles the update case where the user keeps their own email.
What Surprised Me
Schema validation at deploy time. Convex validates your schema when you deploy functions, not at runtime. This caught several bugs during development - mismatched field types, missing indexes - before they could reach production.
No loading states for existing data. When the query is already cached, there's no flash of "Loading..." on navigation. The data is just there. Only the first render of a new query shows undefined.
What I'd Do Differently
Pagination from day one. Early on I loaded all tasks with \.collect()\ - fine for 50 tasks, problematic for 5,000. Adding cursor-based pagination was straightforward but should have been baked in from the start.
Optimistic updates. Convex supports them, but I didn't implement any. For perceived performance on slow connections, mutating a task and having it immediately reflect in the UI (before server confirmation) would meaningfully improve the experience.
Source code: github.com/ionutn0301/convex-task-manager