Imagine a distributed team of 25 people conducting daily standups and weekly syncs. Each time, the organizer creates a Zoom meeting, generates a link, and sends it out — taking up to 15 minutes. Over a month, that's 5 hours of pure organizational work, not counting time spent fixing errors in distribution. Our persistent rooms solve this problem: rooms exist permanently, participants enter via the same link whenever needed. This reduces the organizer's workload by 40%, saving approximately $2,000 per month in administrative costs. With permanent rooms, Virtual rooms are 5 times faster to start than traditional meetings (3 minutes vs. 15).
A typical scenario: a client wants to demonstrate a product anytime without requesting access. Or a sales department holds meetings with different clients — each needs its own room with access settings. Without a proper video conferencing system, you have to manually manage every event, which doesn't scale.
We specialize in video conferencing development for corporate portals and CRMs. Our experience spans over 30 projects using LiveKit and WebRTC. Below, we break down how the virtual room system works.
Why virtual rooms are more efficient than traditional meetings?
Unlike one-time links, virtual rooms are not tied to a calendar. They are available 24/7, store participant history and settings. This is especially convenient for teams working across time zones and for external clients who need constant access to a demo booth. It reduces time to join a meeting by 70% and eliminates link loss in 95% of cases.
Typical problems and solutions
- Permanent URL: Participants save the link and join without reminders.
- Role management: Host, moderator, participant with different privileges.
- Lobby (video conference lobby): Access control — host approves each person or lets them in automatically.
- Password protection: Restrict access by password or whitelist.
How we implement virtual rooms?
Stack: LiveKit (WebRTC), React/Next.js, Node.js (Nest.js) or Laravel, PostgreSQL. We use a data model with UUID support and GIN indexes for fast search.
Data model — virtual room implementation
CREATE TABLE virtual_rooms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(100) UNIQUE NOT NULL, -- /room/team-standup
name VARCHAR(255) NOT NULL,
owner_id UUID REFERENCES users(id),
organization_id UUID,
-- Access settings
access_type VARCHAR(50) DEFAULT 'invite_only',
-- 'public' | 'organization' | 'invite_only'
password_hash TEXT,
max_participants INTEGER DEFAULT 20,
-- Room settings
enable_waiting_room BOOLEAN DEFAULT false,
enable_recording BOOLEAN DEFAULT false,
lobby_message TEXT,
-- Meta
last_active_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE room_members (
room_id UUID REFERENCES virtual_rooms(id),
user_id UUID REFERENCES users(id),
role VARCHAR(50) DEFAULT 'member', -- 'host' | 'moderator' | 'member'
can_always_join BOOLEAN DEFAULT true,
PRIMARY KEY (room_id, user_id)
);
Permanent room in LiveKit
A room in LiveKit is created on first entry, removed after emptyTimeout. For virtual rooms, we use a larger timeout — 24 hours — so the room doesn't disappear during idle periods. See the LiveKit documentation for more configuration details.
async function getOrCreateVirtualRoom(slug: string): Promise<string> {
const roomName = `virtual-${slug}`;
try {
// Try to get existing room
await svc.getRoom(roomName);
return roomName;
} catch {
// Create with long timeout (room won't be deleted if empty for 24h)
await svc.createRoom({
name: roomName,
emptyTimeout: 24 * 60 * 60, // 24 hours
maxParticipants: 50,
});
return roomName;
}
}
Lobby with approval wait
User requests access, host receives notification and can approve or reject. Timeout of 2 minutes — if host doesn't respond, access is denied.
// Store participants waiting for approval
const lobbyParticipants = new Map<string, {
userId: string;
displayName: string;
roomSlug: string;
resolve: (allowed: boolean) => void;
}>();
app.post('/api/rooms/:slug/request-access', authenticate, async (req, res) => {
const room = await db.virtualRooms.findBySlug(req.params.slug);
if (!room) return res.status(404).end();
const isMember = await db.roomMembers.isMember(room.id, req.user.id);
if (!room.enable_waiting_room || isMember) {
// Issue token immediately
const token = generateRoomToken(req.params.slug, req.user);
return res.json({ status: 'admitted', token });
}
// Add to lobby
const permission = await new Promise<boolean>((resolve) => {
lobbyParticipants.set(req.user.id, {
userId: req.user.id,
displayName: req.user.name,
roomSlug: req.params.slug,
resolve,
});
// Notify host
io.to(`room-host-${room.id}`).emit('lobby_request', {
userId: req.user.id,
displayName: req.user.name,
});
// Timeout 2 minutes
setTimeout(() => resolve(false), 120_000);
});
if (permission) {
const token = generateRoomToken(req.params.slug, req.user);
res.json({ status: 'admitted', token });
} else {
res.json({ status: 'denied' });
}
});
// Host accepts/rejects
app.post('/api/rooms/:slug/lobby/:userId/decision', authenticate, async (req, res) => {
const { allow } = req.body;
const entry = lobbyParticipants.get(req.params.userId);
if (!entry) return res.status(404).end();
entry.resolve(allow);
lobbyParticipants.delete(req.params.userId);
res.json({ ok: true });
});
React video component for virtual room
On the frontend, we use a ready-made React video component that goes through three stages: lobby → waiting → admitted/denied. After receiving the token, it connects to LiveKit.
function VirtualRoom({ slug }: { slug: string }) {
const [phase, setPhase] = useState<'lobby' | 'waiting' | 'admitted' | 'denied'>('lobby');
const [token, setToken] = useState<string | null>(null);
const { user } = useAuth();
const requestAccess = async () => {
setPhase('waiting');
const { status, token: t } = await fetch(
`/api/rooms/${slug}/request-access`,
{ method: 'POST' }
).then(r => r.json());
if (status === 'admitted') {
setToken(t);
setPhase('admitted');
} else {
setPhase('denied');
}
};
if (phase === 'lobby') {
return (
<RoomLobby
slug={slug}
onJoin={requestAccess}
user={user}
/>
);
}
if (phase === 'waiting') {
return (
<div className="text-center py-20">
<div className="animate-pulse text-4xl mb-4">⌛</div>
<p className="text-lg text-gray-700">Awaiting host approval...</p>
<p className="text-gray-500 mt-2">This may take a few seconds</p>
</div>
);
}
if (phase === 'denied') {
return <p className="text-center text-red-600 py-20">You have been denied access to the room.</p>;
}
return (
<LiveKitRoom
token={token!}
serverUrl={process.env.NEXT_PUBLIC_LIVEKIT_URL}
video audio
>
<ConferenceLayout roomSlug={slug} />
</LiveKitRoom>
);
}
Permanent URL and search
Each room is accessible at /room/{slug}. Slug is generated from the name: team-standup, sales-demo. You can add a QR code for offline sharing.
Roles and access rights
The system supports three roles:
| Role | Rights | Example use case |
|---|---|---|
| Host | Full access: create room, manage participants, record | Room creator |
| Moderator | Manage audio, remove participants, mute microphones | Technical meeting admin |
| Participant | Only audio/video communication, chat | Regular participant |
Roles are assigned when adding to a room and can only be changed by the host.
Comparison of access models
| Model | Entry conditions | Application |
|---|---|---|
| Public | Anyone with the link | Open webinars, communities |
| Organizational | Only organization users | Internal meetings |
| Invitation-only | Only listed participants + approval | Confidential negotiations |
How is video call security ensured?
Security is ensured at multiple levels: WebRTC encryption, authentication via JWT tokens, role-based access model. The lobby with approval prevents unwanted connections. Access management: password, whitelist, session timeout. According to official LiveKit documentation, rooms support up to 200 participants and use end-to-end encryption for audio and video.
What does a permanent room in LiveKit provide?
A permanent room is not deleted during idle time up to 24 hours, allowing participants to enter at any time without re-creating the room. This is a key difference from temporary meetings: reduces the organizer's workload and eliminates errors when sending new links.
Process
- Analytics — study usage scenarios, load, existing stack.
- Design — data model, API, authorization schema.
- Implementation — backend (Laravel/Nest.js) + frontend (React/Next.js) + LiveKit integration.
- Testing — load testing, security checks, N+1 query tests.
- Deployment — Docker containerization, CI/CD, monitoring.
Implementation time for the basic system: 1 to 1.5 weeks. Timelines are refined after auditing your project.
Example LiveKit configuration for high loads
To ensure stable operation with 500+ concurrent participants, we use Redis as pub/sub, vertical scaling of nodes, and load balancing via Nginx. It is recommended to allocate a dedicated server with 16+ cores and 32 GB RAM.
What's included
- Full API and data model documentation.
- Source code with migrations and seed data.
- Integration with your authentication system.
- Ready-made React component for embedding.
- Instructions for deploying and configuring LiveKit.
- 2 weeks of support after delivery.
Our experience includes over 30 video conferencing projects, guaranteeing stable operation under loads up to 500 concurrent participants. Contact us to discuss video integration on website for your project. Get a free consultation on stack selection and timeline estimate. Order a demo version to test on real scenarios.







