Frontend: https://task-management-app-delta-sable.vercel.app
Backend API: https://task-management-app-rs77.onrender.com/api
- Design Goals
- System Overview
- Frontend Architecture
- Backend Architecture
- Authentication Architecture
- Database Architecture
- Security Architecture
- Load Test Results
- Trade-offs and Decisions
- Future Enhancements
Every architectural decision in TaskFlow traces back to one of these principles:
| Goal | What it means in practice |
|---|---|
| Separation of concerns | Each layer owns exactly one thing. Controllers don't contain business logic. Services don't know about HTTP. |
| Correctness under concurrency | Token rotation is a single atomic DB operation — not two sequential ones with a gap between them. |
| Security by default | Refresh tokens are never stored in plaintext. HttpOnly cookies. Short-lived access tokens. |
| Production readiness | Docker, CI, compound indexes, centralized error handling, environment-based config. |
| Maintainability | Feature-based frontend, layered backend, explicit type contracts at every boundary. |
┌───────────────────────────────────────────────────────────┐
│ React App (Vercel) │
│ │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ Redux Toolkit │ │ TanStack Query │ │
│ │ (client state) │ │ (server state) │ │
│ │ user, authState│ │ tasks, stats, cache, sync │ │
│ └────────┬────────┘ └─────────────┬────────────────┘ │
│ │ │ │
│ └─────────────┬─────────────┘ │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ Axios Client │ │
│ │ + Interceptors │ │
│ └─────────┬──────────┘ │
└─────────────────────────┼─────────────────────────────────┘
│
│ Authorization: Bearer <accessToken>
│ Cookie: refreshToken (HttpOnly, SameSite=Strict)
│
┌─────────────────────────▼──────────────────────────────────┐
│ Express API (Render) │
│ │
│ routes → validators → middleware → controllers → │
│ services → models │
│ │
│ Helmet · CORS · Zod validation · JWT auth middleware │
│ Rate limiting (authLimiter + globalLimiter) │
└─────────────────────────┬──────────────────────────────────┘
│
│ Mongoose ODM
│
┌─────────────────────────▼─────────────────────────────────┐
│ MongoDB Atlas │
│ │
│ users { email, passwordHash, refreshTokens[] } │
│ tasks { title, status, priority, dueDate, userId } │
└───────────────────────────────────────────────────────────┘
src/
├── api/
│ ├── client.ts # Axios instance, request + response interceptors
│ ├── auth.api.ts
│ └── tasks.api.ts
│
├── components/
│ ├── layout/ # AppShell, ErrorBoundary, Navbar, PageSkeleton, ThemeToggle
│ ├── tasks/ # CreateTaskDialog, DashboardStats, DeleteTaskDialog,
│ │ # EditTaskDialog, EmptyState, TaskCard, TaskFilters,
│ │ # TaskForm, TaskList, TaskStats
│ └── ui/ # shadcn/ui primitives (Button, Dialog, Input, etc.)
│
├── features/
│ ├── auth/
│ │ ├── auth.slice.ts # Redux slice: user, accessToken, isAuthenticated, isBootstrapping
│ │ ├── auth.api.ts # loginUser, logoutUser, bootstrapAuthFlow via Axios
│ │ ├── auth.ts # Types & Interfaces: User, AuthState, AuthPayload, AuthResponse
│ │ ├── authForm/ # Reusable form for Login & Register flows
│ │ └── auth.hooks.ts # Custom hooks for login, register, logout via TanStack Query
│ └── tasks/
│ ├── task.api.ts # getTaskStats, getTasks, createTask, updateTask, deleteTask
│ ├── task.hooks.ts # useTasks, useCreateTask, useUpdateTask, useDeleteTask
│ └── task.types.ts # TASK_STATUS, PRIORITY, CreateTaskPayload, UpdateTaskPayload
│
├── hooks/
│ ├── useAppDispatch.ts # Typed useAppDispatch
│ ├── useAppSelector.ts # Typed useAppSelector
│ └── useTaskStats.ts # Hook using TanStack Query
│
├── pages/
│ ├── LoginPage.tsx
│ ├── RegisterPage.tsx
│ ├── DashboardPage.tsx
│ ├── HomePage.tsx
│ └── NotFoundPage.tsx
│
├── routes/
│ ├── AppRouter.tsx # React Router v6 tree
│ ├── ProtectedRoute.tsx # Redirects to /login if not authenticated
│ └── PublicOnlyRoute.tsx # Redirects to /dashboard if already authenticated
│
├── store/
│ └── index.ts # Redux store configuration
│
├── utils/
│ └── authHelper.ts # localStorage read/write/clear, refreshAccessToken,
│ # fetchMe, bootstrapAuth
│
├── types/
│ └── axios.d.ts # Axios interface extension
│
└── providers/
├── QueryProvider.tsx # TanStack Query Client
└── ThemeProvider.tsx # Next Themes
Two separate state layers handle different concerns:
Client State (Redux Toolkit) Server State (TanStack Query)
────────────────────────────── ──────────────────────────────
user: { id, email } tasks list
accessToken: string | null task detail
isAuthenticated: boolean task stats / dashboard
isBootstrapping: boolean
Why two layers? Redux is the source of truth for auth — it needs to be synchronously readable by the router for protected/public-only route decisions before any HTTP response arrives. TanStack Query handles all data that comes from the API: caching, background refetching, stale-while-revalidate, and request deduplication. Using Redux for server state would require manually managing loading/error states that TanStack Query handles automatically.
AppRouter
├── / PublicOnlyRoute → LoginPage (redirect to /dashboard if authed)
├── /login PublicOnlyRoute → LoginPage
├── /register PublicOnlyRoute → RegisterPage
│
├── /dashboard ProtectedRoute → DashboardPage
├── /tasks ProtectedRoute → TasksPage
│
└── * → NotFoundPage
ProtectedRoute reads auth.isAuthenticated from Redux. It also reads auth.isBootstrapping — while bootstrap is in progress, it renders a loading state instead of redirecting, preventing incorrect redirects on page load before auth state is resolved.
On application mount, before any protected content renders:
App mounts → dispatch(bootstrapAuthFlow())
│
├─ accessToken in localStorage?
│ │
│ ▼
│ GET /auth/me (with token in header)
│ ├── 200 → dispatch(setCredentials({ user, accessToken }))
│ │ dispatch(finishBootstrap())
│ │
│ └── 401 → fall through ↓
│
└─ POST /auth/refresh (browser sends HttpOnly cookie automatically)
├── 200 → save new accessToken to localStorage
│ GET /auth/me → dispatch(setCredentials)
│ dispatch(finishBootstrap())
│
└── 401 → dispatch(logout())
dispatch(finishBootstrap())
clear localStorage
Request fires
↓
Attach Authorization: Bearer <accessToken>
↓
Response received
├── 2xx → return normally
└── 401 →
├── Is this /auth/refresh? → dispatch(logout()), reject
└── POST /auth/refresh
├── 200 → update token → retry original request
└── 401 → dispatch(logout()) → redirect /login
Any concurrent requests that 401 simultaneously queue behind a single in-flight refresh call rather than firing multiple refresh requests.
HTTP Request
↓
routes/ — define endpoint paths, compose middleware chains
↓
validators/ — Zod schema validation for body, query, params
↓
middleware/ — authMiddleware, errorMiddleware, validate, rateLimiter
↓
controllers/ — HTTP layer only: read req, call one service, write res
↓
services/ — all business logic: auth, tasks, stats
↓
models/ — Mongoose schema definitions
↓
MongoDB
utils/
├── jwt.ts
│ ├── generateAccessToken(payload) → string pure, no DB
│ ├── generateRefreshToken(payload) → string pure, no DB
│ ├── verifyAccessToken(token) → JwtPayload
│ └── verifyRefreshToken(token) → JwtPayload
│
├── tokenUtils.ts
│ ├── hashRefreshToken(token) → string
│ │ SHA-256, one-way, pure — raw token never stored in DB
│ │
│ ├── issueTokens(user) → { accessToken, refreshToken }
│ │ Pure function. Composes jwt.ts. No DB calls.
│ │ Persistence is always the caller's responsibility.
│ │
│ ├── storeRefreshToken(userId, rawToken)
│ │ Single aggregation pipeline (updatePipeline: true):
│ │ stage 1: $filter — remove expired tokens
│ │ stage 2: $slice — trim to MAX_REFRESH_TOKENS - 1
│ │ stage 3: $concatArrays — append new token hash
│ │ Atomic. No gap between cleanup and insert.
│ │
│ ├── rotateRefreshToken(oldHash, user)
│ │ Single aggregation pipeline, filtered on old hash:
│ │ stage 1: $filter — remove old token + expired tokens
│ │ stage 2: $slice — enforce session cap
│ │ stage 3: $concatArrays — append new token hash
│ │ modifiedCount === 0 → token not found → throws 401
│ │ ATOMIC: no intermediate state between remove and insert
│ │
│ ├── revokeRefreshToken(userId, rawToken)
│ │ $pull exact token by hash (signout)
│ │
│ └── revokeAllRefreshTokens(userId)
│ $set refreshTokens: [] (signout-all)
│
├── AppError.ts AppError(message, statusCode) extends Error
├── asyncHandler.ts wraps async handlers, forwards errors to next()
├── authResponse.ts buildAuthResponse(user, accessToken, refreshToken)
└── cookies.ts refreshCookieOptions, clearCookieOptions
Access tokens are stateless — the server verifies them without a DB call. This keeps the hot path (every authenticated request) at zero database reads for auth. The tradeoff is that access tokens cannot be instantly revoked, so they are kept short-lived (15 minutes). Refresh tokens are long-lived (7 days) and stored server-side as SHA-256 hashes — raw values never touch the database and tokens can be revoked instantly.
/auth/login or /auth/register
↓
issueTokens() — pure, no DB
→ accessToken (JWT, 15m)
→ refreshToken (JWT, 7d)
↓
storeRefreshToken() — single atomic pipeline
filter expired + slice to cap + append new hash
↓
accessToken → JSON response body
refreshToken → HttpOnly SameSite=Strict cookie
hash → users.refreshTokens[]
── 15 min ──→ access token expires
↓
POST /auth/refresh
rotateRefreshToken() — single atomic pipeline
old hash removed + new hash inserted in one operation
↓
new tokens issued, old token gone from DB
The naive two-step approach has a crash window:
step 1: pull old token ← server crash here
step 2: push new token ← never executes = user locked out permanently
The aggregation pipeline approach executes both stages atomically in a single document write:
UserModel.updateOne(
{ _id: userId, "refreshTokens.tokenHash": oldHash },
[
{
$set: {
refreshTokens: {
$filter: {
/* remove old + expired */
},
},
},
},
{ $set: { refreshTokens: { $slice: ["$refreshTokens", MAX - 1] } } },
{
$set: {
refreshTokens: { $concatArrays: ["$refreshTokens", [newToken]] },
},
},
],
{ updatePipeline: true },
);Either the entire rotation succeeds or nothing changes. No intermediate state is possible.
If two requests with the same refresh token hit /auth/refresh simultaneously:
Request A → updateOne → oldHash found → pipeline runs → modified ✓ → new token issued
Request B → updateOne → oldHash GONE → modifiedCount: 0 → throws 401
MongoDB's document-level locking ensures only one write wins. Validated by load testing: zero race conditions across 12,700+ requests at 50 concurrent users.
// User document
{
_id: ObjectId,
email: string, // unique index
passwordHash: string, // bcryptjs, 12 rounds
refreshTokens: [{
tokenHash: string, // SHA-256 of raw token — raw never stored
expiresAt: Date,
}],
createdAt: Date,
updatedAt: Date
}
// Task document
{
_id: ObjectId,
title: string,
description: string,
priority: 'LOW' | 'MEDIUM' | 'HIGH',
status: 'TODO' | 'IN_PROGRESS' | 'DONE',
dueDate: Date,
userId: ObjectId, // ref: User
createdAt: Date,
updatedAt: Date
}users collection
{ email: 1 } unique: true ← login lookup + duplicate check
tasks collection
{ userId: 1, status: 1 } ← GET /tasks?status=TODO
{ userId: 1, priority: 1 } ← GET /tasks?priority=HIGH
{ userId: 1, dueDate: 1 } ← GET /tasks?sortBy=dueDate
{ userId: 1, createdAt: -1 } ← GET /tasks (default sort)
{ userId: 1, status: 1, priority: 1 } ← dashboard aggregation + combined filter
All task indexes are compound with userId as the leading key. MongoDB will never scan across users for any query — every access pattern begins with a userId equality match.
GET /api/tasks/stats runs a single $match → $group → $project pipeline — one round trip, no N+1:
$match: { userId: ObjectId } ← hits compound index
↓
$group: _id: null
total: { $sum: 1 }
todo: { $sum: { $cond: [{ $eq: ["$status", "TODO"] }, 1, 0] } }
inProgress: { $sum: { $cond: [{ $eq: ["$status", "IN_PROGRESS"] }, 1, 0] } }
completed: { $sum: { $cond: [{ $eq: ["$status", "DONE"] }, 1, 0] } }
pending: { $sum: { $cond: [{ $ne: ["$status", "DONE"] }, 1, 0] } }
overdue: { $sum: { $cond: [overdue condition, 1, 0] } }
↓
$project: reshape + compute completionRate = round(completed/total * 100)
Returns: total, todo, inProgress, completed, pending, overdue, completionRate.
Layer 1 — Transport
HTTPS in production (Vercel, Render)
CORS restricted to CLIENT_URL only
Layer 2 — HTTP
Helmet: CSP, X-Frame-Options, X-Content-Type-Options, HSTS
Rate limiting: authLimiter on /auth/* routes, globalLimiter site-wide
Layer 3 — Input
Zod validation middleware on every route
Body, query params, and route params all validated
Invalid input → 400 before reaching controller
Layer 4 — Authentication
Access token: short-lived JWT, verified without DB read
Refresh token: SHA-256 hash only, raw value never persisted
Cookie: HttpOnly, SameSite=Strict, Secure (production)
Layer 5 — Application
Ownership checks in services — userId on every task query
Generic error messages — no email enumeration
asyncHandler catches all async errors → centralized error middleware
Layer 6 — Data
Passwords: bcryptjs, 12 rounds
No sensitive fields in JWT payload (userId + email only)
No tokens, passwords, or secrets in logs
{
"success": false,
"message": "Human-readable message",
"errors": []
}Stack traces never sent to client.
Setup: 100,000 tasks seeded across 500 users. Production build (node dist/server.js). Tested with k6.
Stages: 0→20 VUs (30s) → 50 VUs (2m) → ramp down (30s)
Total duration: 3m 30s
| Metric | Result |
|---|---|
| Peak concurrent users | 50 |
| Total HTTP requests | 12,731 |
| HTTP error rate | 0.00% |
| Auth failure rate | 0.00% |
| Refresh token race conditions | 0 |
| Dashboard query — median latency | 38ms |
| Dashboard query — p90 | 242ms |
| Dashboard query — p95 | 477ms |
| Overall requests — median | 65ms |
| Overall requests — p95 | 441ms |
Zero race conditions across 50 concurrent users firing simultaneous token refresh requests. The atomic MongoDB update pipeline handles concurrent 401 storms without duplicate refresh calls or corrupted token state.
Tested locally on Apple M-series. Production numbers on a dedicated server would differ.
Chosen because: stateless access tokens mean the Express server holds no session state. Any number of server instances can verify tokens independently — important for horizontal scaling.
Cost: access tokens can't be instantly revoked. Mitigated by keeping them short-lived (15 minutes) and using server-side refresh token rotation for longer-lived sessions.
Chosen because: two-step pull-then-push has a crash window between writes. The pipeline eliminates it. Also handles cap enforcement and expired token cleanup in the same operation.
Cost: aggregation pipeline updates require MongoDB 4.2+. MongoDB Atlas runs 7.0 — not a concern.
Chosen because: the task schema is document-shaped with no cross-document joins needed. Flexible schema made early iteration fast.
Cost: schema enforcement is at the application layer (Mongoose + Zod) rather than the database layer.
Chosen because: Redux is synchronous and readable from the router before any HTTP response — necessary for the protected/public-only routing model. TanStack Query handles caching, deduplication, and background sync that Redux would require significant boilerplate to replicate.
Cost: two mental models for state. Mitigated by clear ownership: Redux owns auth, TanStack Query owns everything from the API.
Chosen because: features/auth/ and features/tasks/ are self-contained — hooks, slice, and types live together. Adding a new feature doesn't require touching multiple top-level directories.
- Node.js 22+
- MongoDB (or Docker)
git clone https://github.com/singh-kashish/TaskFlo
cd task-management-appcd server
npm install
cp .env.example .env
npm run devcd web
npm install
cp .env.example .env
npm run devtouch server/.env # fill in values from .env.example
docker compose up --build
# Frontend: http://localhost:5173
# Backend: http://localhost:8000/api| Variable | Description |
|---|---|
PORT |
Server port |
MONGODB_URI |
MongoDB connection string |
CLIENT_URL |
Frontend URL for CORS |
JWT_ACCESS_SECRET |
Access token signing secret |
JWT_REFRESH_SECRET |
Refresh token signing secret |
JWT_ACCESS_EXPIRES |
Access token TTL (e.g. 15m) |
JWT_REFRESH_EXPIRES |
Refresh token TTL (e.g. 7d) |
| Variable | Description |
|---|---|
VITE_API_URL |
Backend API base URL |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register |
— | Create account, returns accessToken + sets refresh cookie |
| POST | /api/auth/login |
— | Authenticate, returns accessToken + sets refresh cookie |
| GET | /api/auth/me |
✓ | Return current user (no DB read — token only) |
| POST | /api/auth/refresh |
cookie | Rotate refresh token, return new accessToken |
| POST | /api/auth/signout |
✓ | Revoke current session's refresh token |
| POST | /api/auth/signout-all |
✓ | Revoke all sessions (refreshTokens: []) |
| GET | /api/tasks |
✓ | List tasks — filter by status/priority, sort by dueDate/createdAt |
| POST | /api/tasks |
✓ | Create task |
| GET | /api/tasks/stats |
✓ | Dashboard aggregation |
| GET | /api/tasks/:taskId |
✓ | Get single task (ownership enforced) |
| PATCH | /api/tasks/:taskId |
✓ | Update task (ownership enforced) |
| DELETE | /api/tasks/:taskId |
✓ | Delete task (ownership enforced) |
| GET | /api/health |
— | Health check |
cd server && npm testCovers: registration, duplicate email rejection, login, task CRUD, auth middleware enforcement, cross-user ownership isolation.
GitHub Actions on push to main and all pull requests:
npm ci+npm run buildfor bothserver/andweb/
Frontend deploys to Vercel, backend deploys to Render automatically on merge.
task-management-app/
├── web/
│ ├── features/
│ ├── components/
│ ├── hooks/
│ ├── store/
│ ├── api/
│ ├── pages/
│ └── utils/
├── server/
│ ├── routes/
│ ├── controllers/
│ ├── services/
│ ├── models/
│ ├── middleware/
│ ├── validators/
│ ├── tests/
│ ├── types/
│ ├── scripts/
│ └── utils/
└── docker-compose.yml
- Dark Mode Support
- Optimistic Updates
- Dockerized Setup
- Automated Testing
- CI/CD Pipeline
- Compound Database Indexes
- k6 Load Testing
| Enhancement | Rationale |
|---|---|
tokensInvalidatedAt field on User |
Instant access token invalidation on signout-all without a blocklist |
| WebSockets for real-time task updates | Push changes across devices without polling |
| RBAC | Multi-role support (admin, member) for team-based task management |
| Activity audit log | Append-only record of create/update/delete events per user |
| Structured logging (Winston + correlation IDs) | JSON logs with requestId tracing across full request lifecycle |
| OpenTelemetry | Distributed tracing for request lifecycle visibility |
MIT