From cosmos-pro
How to keep cosmos.gl smooth at 50K+ points: Float32Array reuse, batched setter calls, label budgeting, simulation warmup, GPU memory budget, capability detection (iOS EXT_float_blend, Android OES_texture_float), and instance lifecycle. Most cosmos.gl bugs are performance bugs dressed as visual bugs. Trigger on: "cosmos.gl performance", "janky animation", "missing nodes", "flashing colors", "Float32Array reuse", "label performance", "GPU memory", "graph.destroy", "WebGL fallback", "iOS Safari graph", "Android low-end graph", or any cosmos.gl perf question.
How this skill is triggered — by the user, by Claude, or both
Slash command
/cosmos-pro:cosmos-performanceThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
The diagnostic rule: most cosmos.gl bugs are performance bugs dressed as
The diagnostic rule: most cosmos.gl bugs are performance bugs dressed as visual bugs.
setConfig called instead of setConfigPartial.enableSimulation left on.When a cosmos.gl integration "feels off," start here before reaching for visual changes.
Allocate once per data-size bucket, mutate in place. Allocating fresh
Float32Array instances inside per-frame loops is the single most common
source of jank, because each allocation pressures the GC and cosmos.gl
interleaves rendering with GC pauses.
Wrong:
function onFilterChange(rows) {
// Allocates a new Float32Array every time the filter moves
const positions = new Float32Array(rows.length * 2);
rows.forEach((r, i) => {
positions[i * 2] = r.x;
positions[i * 2 + 1] = r.y;
});
graph.setPointPositions(positions);
}
Right:
class PositionsBuffer {
private buffer = new Float32Array(0);
ensure(size: number) {
if (this.buffer.length < size * 2) {
// grow geometrically; common bucket sizes 1K, 10K, 100K
const next = nextBucket(size * 2);
this.buffer = new Float32Array(next);
}
return this.buffer.subarray(0, size * 2);
}
}
function onFilterChange(rows) {
const positions = positionsBuffer.ensure(rows.length);
for (let i = 0; i < rows.length; i++) {
positions[i * 2] = rows[i].x;
positions[i * 2 + 1] = rows[i].y;
}
graph.setPointPositions(positions);
}
The buffer grows only when crossing a size bucket. The hot path mutates a slice of the buffer with no allocation.
A single setPointColors(wholeArray) is cheap. 50K individual
color-by-index mutations are catastrophic — each one notifies the GPU and
triggers a buffer upload.
If filter changes touch a small subset of indices, still batch:
// Build a delta array, apply it once
for (const idx of changedIndices) {
const off = idx * 4;
colors[off] = newR; colors[off+1] = newG; colors[off+2] = newB; colors[off+3] = newA;
}
graph.setPointColors(colors);
One setPointColors per visible interaction, never one per index.
cosmos.gl draws point labels via a separate HTML overlay layer (DOM elements positioned by world-to-screen projection). DOM scales O(N). 5K labels chugs; 50K labels destroys the page.
Pattern: render labels for the top-K visible points by zoom level and importance. Re-evaluate which K to show on zoom and on filter change, not on every frame.
function selectVisibleLabels(zoom: number, k: number): string[] {
if (zoom < 1.5) return []; // overview: no labels
if (zoom < 3.0) return topKByPageRank(k); // medium: K by importance
return visibleByImportance(k * 2); // detail: 2K by importance
}
Hard cap from N4: never render more than 5000 labels simultaneously. If
the design wants every-node labels, the design must adapt — see the
"IF CONFLICT" rule in CLAUDE.md.
Run the simulation hot for ~200 ticks on data load (alpha = 1.0, low
friction), then drop to friction 0.85 for interactive exploration. The
alpha lever:
graph.setConfigPartial({
simulationAlpha: 1.0,
simulationFriction: 0.5,
});
// after warmup window (e.g., setTimeout 1500ms or onSimulationEnd):
graph.setConfigPartial({
simulationFriction: 0.85,
});
Without warmup, the user watches the layout slowly find itself over the first 5+ seconds of interaction. With warmup, the layout settles before the user starts brushing histograms.
A cosmos.gl Graph allocates GPU resources. Two Graph instances against
one canvas means leaked allocations and undefined behavior. Two Graph
instances on one page (different canvases) is fine but doubles VRAM
budget — usually only the most-recent rendered graph matters; destroy the
old ones eagerly.
The lifecycle pattern:
useEffect(() => {
const graph = new Graph(canvasRef.current!, initialConfig);
graphRef.current = graph;
return () => {
graph.destroy();
graphRef.current = null;
};
}, []);
In React StrictMode (dev), components mount twice. Without destroy(),
the first instance leaks, claims the canvas, and the second instance
silently no-ops or flickers. This is "works in prod, broken in dev" for
cosmos.gl. Always test in StrictMode.
For hot-reload paths: dispose all Graph instances on module reload. If
the dev server keeps the page alive across edits, the leak compounds
until the GPU runs out of buffer slots.
Check WebGL capabilities on page load. Two known regressions to handle:
function canRunCosmos(): boolean {
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl2");
if (!gl) return false;
if (!gl.getExtension("EXT_float_blend")) return false;
if (!gl.getExtension("OES_texture_float")) return false;
return true;
}
If canRunCosmos() returns false, mount the Sigma 2D fallback component
instead of CosmosGraphCanvas. See recipes/degraded-fallback-2d.md.
| Interaction | What changes | Setter to call |
|---|---|---|
| Hover | Nothing on the graph (DOM tooltip only) | None |
| Click (highlight) | Color of one node + neighbors | setPointColors once |
| Brush filter (small subset) | Color/size of changed indices | setPointColors + setPointSizes once each |
| Filter change (subset) | Positions of remaining points | setPointPositions once, then setLinks |
| Layer change | All positions | setPointPositions once |
| Phase transition | Multiple sets | sequence per applyDirective phase |
Hover does not touch the graph; it only positions a DOM tooltip. Putting
a setConfigPartial call inside an onPointMouseOver is a frequent
performance footgun — every mouse move hits the GPU.
Symptom -> most likely cause:
| Symptom | First thing to check |
|---|---|
| Animation stutters | Float32Array allocation in hot path |
| Some nodes missing | pointPositions.length !== 2 * pointCount |
| Flashing colors | setConfig called instead of setConfigPartial |
| Frozen scene | simulationFriction too high or enableSimulation mismatched |
| Layout never settles | simulationFriction too low; no warmup |
| Crash on iPhone | Capability check missing |
| Janky on dev, fine in prod | StrictMode double-mount; missing destroy() |
| Dev server slowdown over time | Graph leaks on hot reload |
V-perf-1. graph.destroy() is called in every component unmount path.
Grep for new Graph( and verify a matching destroy( in the same file.
V-perf-2. No new Float32Array(...) appears inside a useEffect whose
deps include data props. Allocations belong outside hot paths.
V-perf-3. setPointColors / setPointSizes / setLinkColors calls in
loops are flagged. They should be called once per interaction with a
prebuilt array.
V-perf-4. Label cap is enforced (max 5000 active labels at any moment).
V-perf-5. WebGL capability check runs before mounting CosmosGraphCanvas.
V-perf-6. No setConfig calls outside the initial new Graph(...)
construction. All runtime updates use setConfigPartial.
refs/cosmos-gl/README.mdrefs/cosmos-gl/packages/graph/src/renderer/refs/luma-gl/docs/graph.destroy() in cleanup.setConfig for runtime updates.onPointMouseOver.Provides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Searches MemPalace before answering questions about past work, people, projects, or prior decisions. Returns verbatim stored content instead of guessing from model memory.
npx claudepluginhub travis-gilbert/claude-marketplace --plugin cosmos-pro