In the previous blog, I talked about what I built and the problems I faced.
This time, I want to break down the architecture, not in a dry, textbook way, but in the same way I understood it while building it.
The Mental Model
At a high level, this is what’s happening:
- The frontend behaves like an IDE
- The backend acts like a coordinator
- Workers actually run your code
- Redis connects everything
- S3 stores user solutions
So let’s go step by step.
Step 1: Writing Code (Frontend Sandbox)
When you type JSX in the editor:
- Monaco Editor captures the code
- Babel (standalone) transpiles JSX → JavaScript
- That JS is injected into an iframe
Why iframe?
Because if your code crashes, I don’t want the entire app to crash with it.
So the execution happens inside an isolated environment:
- Separate DOM
- Separate JS context
React and ReactDOM are loaded via CDN, and then your component is rendered inside that iframe.
This gives you instant feedback.
Step 2: Submitting Code
Now comes the important part.
When you click “Submit”:
- The raw JSX (not transpiled code) is sent to the backend
- Backend creates a
solutionId - That ID is returned to the frontend
At this point, nothing has been executed yet.
Step 3: Why WebSockets?
After getting the solutionId, the frontend:
- Opens a WebSocket connection
- Registers itself using that ID
Why not polling?
Because polling would look like:
“Is it done?” → repeat forever
Instead:
- Backend will push the result when ready
This removes unnecessary load and makes the system real-time.
Step 4: The Queue
Here’s where the architecture becomes interesting.
Instead of executing code directly in the backend:
- The backend pushes the job into a queue (BullMQ)
- The job contains: code + metadata
Why?
Because executing user code is:
- Slow
- Unpredictable
- Potentially dangerous
If we did this inside the main server:
- It would block everything
- One bad job could kill performance
So we separate execution.
Step 5: Worker Picks It Up
The worker continuously listens to the queue.
As soon as a job arrives:
- Worker takes the job
- Starts processing
This is completely asynchronous.
The backend doesn’t wait.
Step 6: Execution Pipeline
The worker does this:
- Takes JSX string
- Transpiles it again using Babel
- Launches Puppeteer
- Injects the code into a real browser environment
- Simulates user actions
We don’t just check output.
We simulate behaviour.
For example:
- Find a button
- Click it
- Check if UI updates correctly
This is why frontend transpilation is not enough.
We need:
- A DOM
- An event loop
- Real browser behavior
Node.js alone cannot do this.
Puppeteer solves that.
Step 7: Isolation
Running user code is risky.
So we isolate at multiple levels:
- Each execution runs in a new Puppeteer page
- No shared state between runs
- Worker is separate from the backend process
Even if something goes wrong, it stays contained.
Step 8: Returning the Result
Once execution is done:
- Worker marks job as completed (BullMQ)
- Backend listens to queue events
- Backend finds the correct socket using
solutionId - Emits result via WebSocket
Frontend receives it instantly and updates UI.
Step 9: Redis
Redis handles:
- Queue storage (BullMQ)
- Job events (completed/failed)
- Caching layer (for faster reads)
Earlier, I was using raw Pub/Sub.
But it required:
- Manual channel management
- Manual message parsing
Switching to BullMQ simplified everything:
- Built-in lifecycle events
- Retry handling
- Cleaner architecture
Step 10: Storage with S3
Another problem:
Where do we store user code?
If we store it directly in MongoDB:
- It becomes heavy
- Not scalable
So instead:
- Code is stored in AWS S3
- Only the file key is saved in DB
When needed:
- Backend generates a signed URL
- Frontend uses that to fetch code
This ensures:
- Security (no public access)
- Scalability
- Clean separation
Step 11: The Full Flow
- User writes code
- Code runs in iframe (preview)
- User submits
- Backend returns solutionId
- Frontend opens WebSocket
- Backend pushes job to queue
- Worker picks a job
- Code executed via Puppeteer
- Result emitted via queue event
- Backend sends result via WebSocket
- Frontend updates UI
Key Design Decisions
1. Double Transpilation
Frontend:
- For preview
Backend:
- For validation with real DOM
2. Queue-Based Execution
- Prevents blocking
- Enables scaling workers independently
3. Puppeteer Instead of Node Execution
- Needed for DOM + interaction
- Ensures accurate validation
4. WebSockets Instead of Polling
- Real-time updates
- Lower load
5. S3 Instead of DB Storage
- Better scalability
- Cleaner architecture
Top comments (0)