I wanted to build something that pushed technical boundaries without the bloat. No framework abstractions, no npm install hell, no dependency upgrades at midnight. Just clean architecture, vanilla JavaScript, and a whole lot of interesting problems to solve. Here's how I did it.
Why Zero Dependencies?
The obvious question: why build this from scratch? React, Vue, Svelte—they're all solid choices. But I had specific goals:
- Complete control over performance—no hidden re-renders or virtual DOM overhead
- Understand every byte that ships to the browser
- Learn how modern patterns work without framework magic hiding the details
- Build something that feels uniquely mine, not templated
The trade-off: I had to build a lot myself. EventBus, routing, state management, security. But that's where the learning lives.
Domain-Driven Design with Bounded Contexts
I structured the codebase around three bounded contexts: Portfolio, Playground, and Chat. Each is independent. Each owns its state, rendering, and business logic.
The Portfolio context handles project showcases and metadata. Playground manages interactive tools and experiments. Chat wraps the WebLLM integration and RAG system. They don't touch each other's internals—they communicate through events.
// Architecture entry point: context loaders
const contexts = {
portfolio: async () => await import('./contexts/portfolio.js'),
playground: async () => await import('./contexts/playground.js'),
chat: async () => await import('./contexts/chat.js')
};
// Each context bootstraps independently
async function initContext(name) {
const ContextModule = await contexts[name]();
return ContextModule.init({
eventBus: globalEventBus,
state: appState[name]
});
}This structure scaled beautifully. When I added the chat context with WebLLM, I didn't touch existing code. Didn't need to. The EventBus handled coupling.
EventBus: The Communication Backbone
Loose coupling is critical. I built a simple pub/sub EventBus as the nervous system:
class EventBus {
constructor() {
this.listeners = new Map();
}
on(event, handler) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(handler);
}
emit(event, data) {
if (!this.listeners.has(event)) return;
this.listeners.get(event).forEach(h => h(data));
}
}
// Usage across contexts
eventBus.on('project:selected', (project) => {
// Chat context reacts without knowing about Portfolio
chatContext.selectProject(project.id);
});
// Portfolio publishes
eventBus.emit('project:selected', {id, title, description});This pattern keeps contexts orthogonal. Portfolio doesn't know Chat exists. Chat can subscribe to Portfolio events. Perfect decoupling.
Three.js Particle Background: 3-Layer Depth
The background isn't just visual eye candy—it's a performance study. I use Three.js but keep the scene intentionally simple: particles across three depth planes, no complex geometry.
The trick is using typed arrays for particle positions and velocities. JavaScript arrays trigger memory allocations; typed arrays are fixed buffers that the GPU loves.
// Typed arrays for 3000 particles
const positions = new Float32Array(3000 * 3);
const velocities = new Float32Array(3000 * 3);
// Update positions every frame (no allocation)
for (let i = 0; i < 3000; i++) {
positions[i * 3] += velocities[i * 3];
positions[i * 3 + 1] += velocities[i * 3 + 1];
}
// Mark for GPU update
geometry.attributes.position.needsUpdate = true;Three depth planes (Z: -100, 0, 100) create parallax. Mouse movement affects particle velocity. The result: smooth 60fps on most devices, even while other contexts load.
WebLLM + Client-Side RAG
The Chat context runs a quantized LLM entirely on the client. No API calls, no cold starts. But raw LLM isn't useful for a portfolio context—it needs to know about my projects, skills, and background.
Enter RAG: Retrieval Augmented Generation. I embed project descriptions and metadata into vectors, store them client-side, and inject relevant context into the prompt when users ask questions.
The RAG pipeline: user question → embed query → find similar projects → inject into system prompt → generate response. All client-side. Privacy by design.
Performance Tricks: Lazy Loading and Frame Skipping
I implemented frame skipping for non-critical updates. Particle physics runs every frame. Project animations run every frame. But analytics and DOM scans? Those run every 4th frame, and users never notice.
- Typed arrays: No allocation during particle simulation
- Lazy context loading: Portfolio, Playground, Chat only initialize when first accessed
- Frame skipping: Non-critical work runs at 15fps instead of 60fps
- RequestAnimationFrame pooling: Single RAF loop, batched updates
- IntersectionObserver: Images load only when near viewport
The result: First Contentful Paint at 850ms, Time to Interactive at 1.2s, even with Three.js and WebLLM bundled.
The Security Module: 6 Layers, 62+ Regex Patterns
With client-side LLM and user input, security is non-negotiable. I built a multi-layer defense:
- Input sanitization with 12 patterns (SQL injection, XSS vectors, prompt injection)
- Output escaping with HTML entity encoding
- Rate limiting: 10 requests per 60 seconds per user
- Prompt injection detection: 15 patterns for common attack vectors
- Content Security Policy headers
- Subresource Integrity for external scripts
62+ regex patterns might sound excessive, but this is a high-visibility target. Every pattern blocks a real attack vector I've seen in the wild or in research.
Lessons and Regrets
Would I do this again? Yes, but differently. The biggest lessons:
- Testing matters more at scale. Without unit tests, refactoring felt risky by month three. I eventually added Vitest with zero config overhead.
- EventBus can become a hairball. I added event namespacing and event history tracing early. Worth it.
- Type safety is a superpower. JSDoc with TypeScript checking caught more bugs than I expected. Worth learning the syntax.
- Performance isn't free. The typed arrays, frame skipping, lazy loading—these required profiling and iteration. No silver bullets.
- Security is ongoing. As threats evolve, regex patterns age. I built a system to retire old patterns and add new ones without redeploying.
If I could reset, I'd start with tests and type checking from day one. I'd also spend more time designing the security layer upfront rather than bolting it on. The architecture held, but security was an afterthought initially.
What's Next
The portfolio is a living system. I'm experimenting with real-time collaboration features (SharedArrayBuffer + Workers), multi-modal RAG (image search), and a plugin system to let others extend the Chat context.
Zero dependencies isn't a dogma—it's a design principle. If I find a problem that truly needs a library, I'll add it. But every time I reach for npm, I ask: "Can I build this in 50 lines?" Usually, the answer is yes. And when it is, I learn something.