Back to Blog
Node.jsRedisSocket.ioPostgreSQLSystem Design

Designing a Multi-Tenant CRM with Node.js, Redis, and Socket.io

March 25, 2025 12 min readby Nadipalli Jaswanth

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

View on GitHub
Resume