FlowCRM started as a simple lead tracker and grew into a production-grade multi-tenant platform. Along the way I made a lot of architectural decisions — some I'd make again, some I wouldn't. This is the story of what I built, why, and what I'd do differently.
The Multi-Tenancy Problem
Multi-tenancy means multiple organizations share the same infrastructure, but their data is completely isolated. There are three common approaches:
- Separate databases per org — strongest isolation, but operationally expensive.
- Separate schemas per org — good isolation, moderate complexity.
- Shared schema with org_id column — simplest, requires disciplined query filtering.
I chose the shared schema approach with an organizationId foreign key on every table. Every query in the app is scoped to the authenticated user's organization. To enforce this, I created a middleware layer that injects req.orgId and a Prisma extension that automatically appends where: { organizationId: req.orgId } to every query.
RBAC at the Organization Level
Standard role-based access control (RBAC) assigns permissions to users. In a multi-tenant system, the same user can have different roles in different organizations. I modeled this with a OrganizationMember join table:
model OrganizationMember {
id String @id @default(cuid())
userId String
organizationId String
role OrgRole // OWNER | ADMIN | MEMBER
user User @relation(fields: [userId], references: [id])
organization Organization @relation(fields: [organizationId], references: [id])
@@unique([userId, organizationId])
}Route guards check the user's role within the specific organization before allowing destructive actions. An ADMIN in Org A cannot access Org B even if they're a member.
Real-Time with Org-Scoped Socket.io Rooms
When a user connects via WebSocket, they're automatically joined to a room named org:{orgId}. Any event emitted to that room — new lead, deal update, notification — only reaches members of that organization:
// On connection
socket.join(`org:${user.organizationId}`);
// When a lead is created
io.to(`org:${orgId}`).emit("lead:created", { lead });This pattern is simple but bullet-proof. Users in different organizations never receive each other's events regardless of how many organizations are running on the same server process.
Bull Queues for 3x Faster API Responses
The biggest performance win came from offloading slow operations to Bull queues. Operations like sending email notifications, processing CSV exports, and triggering webhooks were originally done synchronously inside the request handler. This caused API responses to block for 1.5–3 seconds.
With Bull + Redis, the request handler enqueues the job and immediately returns a 202 Accepted. A separate worker process picks up the job and executes it asynchronously. API response times dropped from ~1.8s average to ~600ms — roughly a 3x improvement.
// Enqueue instead of executing inline
await emailQueue.add("send-welcome", {
to: user.email,
orgName: org.name,
});
// Worker (separate process)
emailQueue.process("send-welcome", async (job) => {
await sendWelcomeEmail(job.data);
});Per-Org Rate Limiting on the AI Assistant
Each organization gets an AI assistant powered by Nvidia's Nemotron model with streaming inference. To prevent one org from monopolizing resources, I implemented per-org rate limiting using Redis sorted sets:
const key = `rate:${orgId}:ai`;
const now = Date.now();
const windowMs = 60_000; // 1 minute
await redis.zremrangebyscore(key, 0, now - windowMs);
const count = await redis.zcard(key);
if (count >= 20) throw new TooManyRequestsError();
await redis.zadd(key, now, `${now}-${Math.random()}`);
await redis.expire(key, 60);What I'd Do Differently
- Add Prisma middleware for org scoping from day one. I bolted it on later and had to audit every query for missing filters.
- Use Redis Pub/Sub instead of in-process Bull workers for horizontal scaling — Bull workers don't distribute across multiple Node processes without additional config.
- Index org_id on every table from schema design, not as an afterthought. Adding indexes to a 100K-row table in production blocks writes.
Result
FlowCRM now handles 1,000+ concurrent users with average API latency under 600ms, real-time events under 50ms, and complete data isolation between all organizations — all on a single Node.js process with Redis and PostgreSQL.
Source code available on GitHub
by Nadipalli Jaswanth — Full Stack Developer & AI Engineer