A real-time technical interview platform. Interviewer creates a room, shares a link with a candidate, and both get a shared code editor, video call, chat, and automatic test execution — with an AI-generated summary once the interview wraps up.
Live coding round without needing CoderPad or a Zoom+Google-Doc combo.
This is hosted from my local machine via Cloudflare Tunnel, not a persistent deployment — the link changes each time it's spun up and may not always be live. Reach out and I can spin it up for a live walkthrough.
The Docker-socket sandbox (spawning containers directly via child_process.spawn + docker run, with the worker mounting /var/run/docker.sock) means this can't run on typical PaaS platforms like Railway, Render, or Vercel — they don't expose the Docker socket or allow privileged containers, for good security reasons. This needs a VM with full root access (a proper VPS) to deploy persistently.
Right now this runs locally via Docker Compose and is exposed for demos through Cloudflare Tunnel, which is why the live link above isn't a fixed URL. The planned production setup is a hybrid deployment: frontend on Vercel, backend + worker + Postgres + Redis on a VPS (Hetzner or Oracle Cloud free tier) behind Caddy for SSL.
I was prepping for backend roles and doing a lot of mock interviews with friends, and every time we'd end up cobbling together some mix of a Google Doc, a Zoom call, and someone manually running code locally. I wanted to build something that actually reflects what these platforms do under the hood — sockets, job queues, sandboxed execution — instead of another CRUD app. It also doubled as a way to prove I can build something with real-time constraints, not just REST endpoints.
Or watch directly: https://youtu.be/SKjLCavhb2M
- Interviewer signup/login, candidates join as guests (no account needed)
- Create an interview, attach one or more questions from a question bank
- Shareable candidate join link — candidate enters name/email, gets dropped straight into the room
- Live code editor (Monaco) synced between interviewer and candidate over WebSockets
- Code execution in an isolated Docker container, with pass/fail results per test case
- Text chat during the interview
- Video call between interviewer and candidate (WebRTC, signaled through the same socket connection)
- Interviewer can mark the interview complete, which triggers an AI-generated summary (strengths, weaknesses, hire/no-hire recommendation) based on the code written and the chat transcript
- Multiple questions per interview, switchable mid-session
Two separate Node processes talk to the same Postgres database and Redis instance:
- API server — Express app handling auth, interview CRUD, and the Socket.IO server for real-time stuff (code sync, chat, WebRTC signaling).
- Worker — a standalone BullMQ worker that picks up code submissions off a queue, runs them in a Docker container, and writes results back to the DB.
The two processes don't talk to each other directly. When the worker finishes a job, it publishes a message on a Redis channel; the API server is subscribed to that channel and forwards the result to the right Socket.IO room. This is the part that took the longest to get right, mostly because I kept trying to have the worker emit socket events directly, which doesn't work since it's a different process with no access to the io instance.
Interviewer/Candidate (browser)
|
| HTTP (auth, CRUD) + WebSocket (code sync, chat, WebRTC signaling)
v
API Server (Express + Socket.IO)
|
| enqueue submission
v
Redis (BullMQ queue)
|
v
Worker process --- spawns ---> Docker container (sandboxed execution)
|
| writes result to Postgres, publishes to Redis pub/sub channel
v
API Server picks up pub/sub message --> emits Socket.IO event to the room
Backend: Node.js, TypeScript, Express, PostgreSQL, Prisma, Socket.IO, BullMQ, Redis, Docker, Groq (llama-3.3-70b-versatile) for the AI summary
Frontend: Next.js (App Router), TypeScript, Tailwind, shadcn/ui, Zustand, Axios, React Hook Form + Zod, Monaco Editor, Socket.IO client
I picked this stack mostly because it's what I already knew well enough to move fast, and it's close to what I'd expect a backend role to actually use.
Backend is a flat, type-based structure rather than a monorepo — I originally planned apps/api-server, apps/worker, packages/shared, but that turned into more setup overhead than it was worth for a solo project, so I dropped it.
backend/
├── src/
│ ├── routes/
│ ├── controllers/
│ ├── services/
│ ├── validators/
│ ├── middlewares/
│ ├── sockets/
│ ├── queues/
│ ├── lib/ # prisma client, redis, groq client
│ ├── utils/
│ └── config/
├── prisma/
│ ├── schema.prisma
│ ├── migrations/
│ └── seed.ts
Frontend uses a feature-based structure. No services/ folder — API calls live inside each feature's own api.ts so you don't have to jump between folders to find what a component depends on.
frontend/
├── src/
│ ├── app/ # Next.js routes
│ ├── features/
│ │ ├── auth/
│ │ ├── interview/
│ │ ├── interview-room/
│ │ ├── editor/
│ │ └── ...
│ ├── components/ui/ # shadcn generated
│ ├── hooks/
│ ├── stores/ # zustand
│ └── types/
Prerequisites: PostgreSQL and Redis running locally
Update backend/.env with DATABASE_URL and REDIS_HOST/REDIS_PORT
cd backend npm install npx prisma migrate dev npx tsx prisma/seed.ts npm run dev # starts the API server, port 3005 npm run worker # starts the BullMQ worker, separate terminal
cd frontend npm install npm run dev # port 3001
Both the API server and the worker need to be running for submissions to actually get processed — if you only start npm run dev, submit will hang forever because nothing's consuming the queue.
Backend .env
DATABASE_URL=postgresql://user:password@localhost:5432/interview-room
REDIS_URL=redis://localhost:6379
JWT_AUTH_SECRET=
JWT_ROOM_SECRET=
GROQ_API_KEY=
FRONTEND_URL=http://localhost:3001
Frontend .env.local
NEXT_PUBLIC_API_URL=http://localhost:3005/api/v1
NEXT_PUBLIC_SOCKET_URL=http://localhost:3005
When a candidate hits submit, the code doesn't run in the API process — it goes into a queue.
POST /interviews/:id/questions/:iqId/submitwrites aSubmissionrow (status pending) and enqueues a job with the submission ID.- The worker picks it up, wraps the candidate's function in a small test harness (basically: define the function, run it against each test case, print results as a JSON line to stdout), and writes that to a temp file.
- That file gets mounted into a fresh Docker container (
node:20-alpine) and executed. - The container runs with
--network none,--read-only, a memory cap, a CPU cap, and a process limit — mainly so a candidate's code can't reach out to the internet, write to disk, fork-bomb the host, or spin forever. There's a hard timeout on top of that in case something hangs anyway. - The worker parses the container's stdout, updates the
Submissionrow with pass/fail counts, and publishes a message on Redis so the API server can push the result to the browser over the socket.
I went with BullMQ instead of just running child_process.spawn inline in the request handler because I didn't want a burst of submissions to spike the API server or block other requests — the queue means submissions get processed one (or a few, depending on concurrency) at a time by a process that's isolated from what's serving actual HTTP traffic. It also means if the worker crashes, the API server keeps running and submissions just wait in the queue instead of getting dropped.
Only JavaScript is supported right now. There's a LANGUAGE_IMAGES map in the sandbox runner that's set up to support more languages later — mostly just needs images and matching harnesses per language, but I didn't get to that.
Socket.IO is used for everything that needs to update instantly: code sync, chat, WebRTC signaling, and pushing submission results. I picked Socket.IO over raw WebSockets mainly for the room abstraction (socket.join) and automatic reconnection — didn't want to hand-roll that.
Auth happens once, at connection time, through a Socket.IO middleware that verifies a short-lived Room JWT and attaches the decoded payload (interviewId, role, userId/candidateName) to socket.data. Every event handler after that trusts socket.data.room instead of re-checking auth per event.
- Code sync: candidate/interviewer emits
code:change, server upserts aCodeSessionrow (keyed oninterviewQuestionId, notinterviewId— this matters if you support multiple questions per interview) and broadcastscode:updateto everyone else in the room viasocket.to(), which excludes the sender so you don't get an echo back into your own editor. - Chat: uses
io.to()instead ofsocket.to(), since the sender should see their own message appear too. - WebRTC signaling: offer/answer/ICE candidates are just relayed through the socket to the other person in the room — the server doesn't do anything with the SDP itself, it's a dumb pipe.
- Submission results: this is the one event that doesn't originate from a socket handler. It comes from the Redis pub/sub subscriber picking up a message from the worker process and calling
io.to(interviewId).emit(...).
Rough shape (see prisma/schema.prisma for the full thing):
- User — interviewers only. Candidates aren't users; they're just a name + email stored directly on the
Interviewrow. - Interview — one interview session, has a status (
SCHEDULED,IN_PROGRESS,COMPLETED,CANCELLED), belongs to one interviewer. - Question — a standalone bank of coding questions, independent of any interview.
- InterviewQuestion — join table between
InterviewandQuestion(many-to-many), with its ownorderandstatus. This is also whatCodeSessionandSubmissionkey off of, not the rawquestionId, since the same question could theoretically be reused across interviews. - CodeSession — one per
InterviewQuestion, stores the live code and language so a refresh doesn't lose progress. - Submission — one row per submit click, stores test results as JSON.
- ChatMessage — flat log, tied to the interview.
- AISummary — one per interview, generated once and cached (calling complete twice won't regenerate it).
I went with Postgres + Prisma mostly because I wanted actual relations and migrations, not because I benchmarked it against alternatives. Prisma's schema-as-source-of-truth approach is also just easier to reason about when you're the only one working on the DB.
- Two separate JWTs: a longer-lived Auth Session token for interviewers (login), and a short-lived Room token scoped to a single
interviewIdfor anyone actually in the room. This means a leaked Room token only exposes one interview session, not the interviewer's whole account. - No refresh tokens. If the Auth Session token expires, you log in again. Was a deliberate scope cut, not an oversight — refresh token rotation adds a fair bit of complexity I didn't think was worth it for this.
- Docker sandbox runs candidate code with no network access, read-only filesystem, and hard resource limits — the assumption here is that candidate code is fully untrusted and should never be able to touch the host or make external calls.
- Ownership checks on interview data — an interviewer can only view/modify interviews they created, checked at the service layer using the
subclaim from the Auth JWT. - CORS is locked to the frontend origin, not wildcarded, in the socket and Express config.
Things I know are weak points: no rate limiting on submission spam, sessionStorage for the Room token means refreshing a tab that lost its token kicks you back to needing to rejoin, and there's no audit log of who did what in a room.
- Support more languages in the sandbox (Python, Java) — the plumbing's mostly there, just needs images and harnesses
- Rate limit submissions per interview to stop someone from spamming the queue
- Recording/playback of the session
- Resume/reconnect flow if a candidate's Room token is lost mid-interview
- Proper multi-interviewer support (panel interviews)
MIT









