This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.
Background
FaroIQ is a strategic intelligence platform for nonprofits that I built for the Microsoft Agents League Hackathon 2026. It runs a 9-agent pipeline on Azure AI Foundry, produces a full strategic report in under 90 seconds, and executes automatically into Microsoft 365.
The hero section was one of the most important design decisions of the project. In a hackathon, the first screen a judge sees defines the impression of everything that follows. I wanted something that represented the name FaroIQ, which means lighthouse in Spanish, without falling into the generic hero with a gradient, a headline, and a CTA button that every project ends up with.
So I built a fully custom 3D lighthouse with Three.js from scratch: cylindrical tower segments with alternating stone and stripe materials, a lantern room with a rotating beam using a SpotLight and additive blending cones, animated wave geometry updated every frame, star particles in dark mode, a sun sphere in light mode, directional and point lights, fog with different density between modes, and a lens sphere with pulsing opacity. The lighthouse was not decorative. It was functional to the concept and it was what made the hero feel like something built with intention rather than assembled from a template.
The app also had a dark and light mode toggle. The lighthouse had to look completely different in each mode. Different fog density, different beam intensity, different water color, stars appearing and disappearing, moon versus sun. That meant the component received an isDark prop and rebuilt its entire material palette and scene configuration based on it.
Environment
| Framework | React 18 + Vite |
| Language | TypeScript |
| 3D library | Three.js r168 |
| Deployment | Vercel |
| Affected environments | Local development and production |
| Browser | Chrome, Firefox, Safari (all affected) |
What was happening
The bug had multiple faces and none of them were consistent, which made it harder to pin down.
On the first load, the lighthouse sometimes did not finish rendering at all. The browser would stall partway through the Three.js initialization and the canvas would stay black. In production, a judge opening the app for the first time might see nothing in the hero section.
When the user clicked the theme toggle, one of several things happened depending on timing and how much the browser had already used:
- The lighthouse froze mid-transition with both the old and new scene partially rendered at the same time
- The scene switched correctly but the browser's memory footprint climbed with each toggle
- Nothing happened at all and the lighthouse stayed stuck in the previous mode regardless of the new
isDarkvalue - The browser tab became unresponsive and had to be killed The performance tab in Chrome DevTools showed the GPU memory climbing with each theme switch instead of staying flat. The console showed:
WARNING: Too many active WebGL contexts. Oldest context will be lost.
And intermittently:
WebGL: INVALID_OPERATION: drawArrays: no buffer is bound to enabled attribute
The bug was reproducible every time. Sometimes it took one toggle to surface it, sometimes two or three but it always happened.
Prerequisites to understand the bug
How React's useEffect cleanup works with dependencies
When a useEffect has dependencies, React runs the cleanup function and then re-runs the effect whenever any dependency changes. This happens within the same component instance. The component does not unmount. The refs stay the same. The DOM node stays the same.
How WebGL contexts work in the browser
Every new THREE.WebGLRenderer() call creates a new WebGL context bound to a new canvas element. Browsers impose a hard limit on how many active WebGL contexts a page can have. Chrome's limit is around 16. When that limit is exceeded, the browser starts discarding the oldest contexts to make room for new ones.
The critical detail is that renderer.dispose() releases the Three.js resources on the JavaScript side, but the GPU does not necessarily free the underlying context memory synchronously. The browser's GPU process has its own lifecycle that does not block the JavaScript thread. Calling dispose() and then immediately calling new THREE.WebGLRenderer() in the same component lifetime can create a new context before the old one is fully released at the GPU level.
This is the gap the bug lived in.
The component structure
LighthouseBackground was always its own isolated component, which was the right decision. All the Three.js setup, animation loop, and cleanup lived inside a single useEffect:
export function LighthouseBackground({ isDark = true }: Props) {
const mountRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(W, H);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
mount.appendChild(renderer.domElement);
// ... full scene setup: materials, geometries, lights, animation loop
let id: number;
const animate = () => {
id = requestAnimationFrame(animate);
renderer.render(scene, camera);
};
animate();
return () => {
cancelAnimationFrame(id);
window.removeEventListener("resize", onResize);
if (mount.contains(renderer.domElement))
mount.removeChild(renderer.domElement);
renderer.dispose();
};
}, [isDark]);
return <div ref={mountRef} style={{ position: "absolute", inset: 0 }} />;
}
The cleanup looked correct. Cancel the animation frame, remove the canvas, dispose the renderer. All the right things in the right order.
The problem was that with [isDark] as the dependency, React ran this cleanup and immediately re-ran the effect in the same component instance when the theme changed. The old WebGL context was not guaranteed to be fully released by the GPU before the new WebGLRenderer constructor call created another one. With a scene this heavy, that gap was enough to trigger the memory accumulation and the browser warnings.
The ThemeToggle component was wiring directly into the theme context:
export function ThemeToggle({ theme, toggle }: Props) {
return (
<button onClick={toggle} aria-label={...}>
{theme === "dark" ? <FiSun size={15} /> : <FiMoon size={15} />}
</button>
);
}
Each click updated theme in the context, which re-rendered HeroSection, which passed the new isDark to LighthouseBackground, which triggered the effect cycle described above.
Reproduction steps
- Clone the repository and run
npm install && npm run dev - Open the app in Chrome with DevTools open on the Performance and Console tabs
- Observe the lighthouse on initial load. Note whether it finishes rendering
- Click the theme toggle button once
- Observe the console for WebGL warnings
- Click the toggle four or five more times in quick succession
- Watch the GPU memory in the Performance tab. It climbs instead of staying flat In production the same steps apply on the Vercel deployment.
Why simplifying the geometry was not the answer
The obvious alternative was to reduce the complexity of the lighthouse. Fewer polygons, simpler materials, basic shapes.
The problem with that is that a lighthouse made of cylinders and cones with flat shading and no texture is just a cartoon. It would not have represented FaroIQ in any meaningful way. The whole point of building a custom 3D scene instead of a generic hero was that the lighthouse was recognizable as a lighthouse and felt like it belonged to the project.
Reducing to spheres or abstract geometry would have solved the performance problem by removing the thing that was worth keeping. That is not a fix.
The actual optimizations that helped performance without compromising the visual were:
- Capping
pixelRatioat 1.5 to avoid running at 3x or 4x on high DPI displays - Using
MeshPhongMaterialinstead ofMeshStandardMaterialacross the scene, which skips physically based lighting calculations - Setting segment counts conservatively on each geometry, enough to read as round without subdividing unnecessarily
- Using
MeshBasicMaterialfor the beam cones and lens sphere since those elements do not need lighting Those changes made the initial load faster. They did not fix the WebGL context leak.
What didn't work
Before arriving at the key prop, there were two attempts that seemed reasonable but did not solve the problem.
The first was moving the cleanup logic into a separate component that would act as a wrapper and handle teardown independently. The idea was that decoupling the renderer lifecycle from the scene setup might give the GPU more breathing room between context changes. In practice it made things worse. The cleanup still happened within the same React tree lifecycle and the WebGL context still accumulated. What it did add was complexity: now the lighthouse logic was split across two components with no clear ownership of the renderer, which made the code harder to reason about for something as specific and self-contained as a 3D scene.
The second attempt was separating the material definitions from the renderer setup, building the material palette outside the main useEffect so that only the renderer and scene would re-initialize on theme change while the materials would be shared. This direction had a similar problem. The materials in Three.js are tied to the WebGL context they were created in. Sharing materials across renderer instances does not work the way sharing JavaScript objects does. More importantly, pulling the material definitions out of the component meant scattering the lighthouse configuration across multiple files for a component that was always going to live in one place and serve one purpose. The cognitive cost was not worth it.
Both attempts were solving the symptom, which was the re-initialization cost, rather than the actual problem, which was the component instance persisting across changes that required a full reset.
Memory behavior before and after the fix
Before the fix, the GPU memory pattern in Chrome DevTools was visible without needing exact measurements. Each theme toggle added a layer of memory that did not come back down. The first toggle was usually smooth. By the third or fourth, the page started producing small freezes, the kind where the animation loop stalls for a fraction of a second and the lighthouse beam stops mid-rotation before catching up. By the sixth or seventh toggle in quick succession, the browser tab either threw the WebGL context warning and lost the scene entirely, or became unresponsive and had to be killed.
The experience was not just a visual problem. Because the lighthouse has a continuous animation loop updating the wave geometry every frame and rotating the beam, a stalled renderer was immediately perceptible. The scene did not degrade gracefully. It either worked or it froze.
After the fix, the memory line in the performance tab stayed flat across theme switches. The full lighthouse unmounts cleanly, the GPU releases the context, and a fresh instance starts from a clean state. The transition between day and night now feels instant. The beam keeps rotating, the waves keep animating, and the fog density shift from the dense dark mode to the lighter day mode happens without any visible frame drop.
The difference between the two behaviors is the difference between a WebGL context that accumulates and one that has a defined lifetime.
Browser differences
The bug was tested in Chrome and Firefox. Both showed the same core behavior: memory accumulation with each theme toggle and eventual WebGL context loss. Chrome was more explicit about it with the console warning naming the context limit directly. Firefox manifested it more as progressive slowdown and frame drops rather than a hard error, but the underlying cause was identical.
Safari has a lower WebGL context limit than Chrome, around eight compared to Chrome's sixteen. In a hackathon where judges can open the project on any machine and any browser, that matters. A bug that takes six toggles to crash Chrome might crash Safari on the second or third. The fix applies equally to all of them since it addresses the root cause rather than pushing the limit further away.
The fix
One prop in HeroSection:
<LighthouseBackground key={theme} isDark={isDark} />
The key prop changes what React does at the component level. Without it, a theme change updates the existing LighthouseBackground instance. The effect cleanup runs and the effect re-runs in the same component lifetime. The same DOM node, the same refs, the same component.
With key={theme}, React treats the component as a different element when theme changes. It fully unmounts the old LighthouseBackground, running the cleanup and removing it from the tree entirely. The old canvas element is removed from the DOM. The old renderer is disposed. The old component instance is gone. Only then does React mount a fresh LighthouseBackground with new refs, new state, and a new Three.js scene.
This gives the browser a genuine boundary between the old renderer's lifetime and the new one's. The GPU context count stays at one. No memory accumulation. No stale context errors.
// HeroSection.tsx
const isDark = theme !== "light";
return (
<section ...>
<LighthouseBackground key={theme} isDark={isDark} />
...
</section>
);
The key here is theme, which is the string "dark" or "light". When it changes, React sees a component with a different key and treats it as a complete replacement.
How it ended up documented
The fix was intentional enough that it ended up in the project README:
"Theme changes force a full remount of the Three.js scene via a React key prop to ensure clean state."
That single sentence captures what took several debug sessions to arrive at. The rest of the theme system description in the README covers what the switch actually does visually: cream and red tower with a bright sky, sun, and ambient light in day mode; dark navy and blue tower with a starfield, moon, and volumetric beam in night mode. The visual difference between modes was significant enough that a partial or broken transition was immediately obvious to anyone looking at the page. That visibility was part of what made fixing it non-negotiable.
What I took from this
useEffect cleanup is not a component teardown. It is a side effect reset within the same component lifetime. For most cases, that distinction is irrelevant. For APIs that hold GPU resources, the difference between updating an instance and replacing it is the difference between a working app and a browser that runs out of WebGL contexts.
The key prop as a remount trigger is not a workaround. It is the correct React pattern when a prop change requires a full reset of imperative resources rather than an in-place update. Using it intentionally is different from using it as a patch for an architecture problem.
The performance optimizations around pixelRatio and material choice were real improvements and should have been there from the start. But they were solving a different problem. A lighter scene still leaks WebGL contexts if the underlying issue is not addressed.
The lighthouse works now. Theme switching is instant, memory stays flat, and the browser stays alive.
FaroIQ was built for the Microsoft Agents League Hackathon 2026. Demo available at faroiq.vercel.app. Source on GitHub.
Top comments (0)