Debugging playbook#
This page is the “if it’s broken, do this” playbook for the Cellucid web app.
It is intentionally procedural: follow it top-to-bottom when you hit a bug, and you will usually end up with a minimal reproduction and the exact clues needed to fix it.
At a glance#
Audience
Wet lab / non-technical: use this to capture actionable bug reports.
Computational users: use this to diagnose data/loading/field issues quickly.
Developers: use this to debug state/rendering/persistence regressions.
Time
5–10 minutes for most issues to get to a good bug report
20–60 minutes for complex performance/memory issues
Step 1: Record the environment (always)#
Before you click anything else, write down:
Where you ran Cellucid
Hosted:
https://www.cellucid.comLocal app:
http://localhost:8000(your owncellucid/checkout)A viewer opened by Python (
show_anndata(), in a notebook or a browser tab) or R (cellucid_view())
Browser + OS
Browser name + version
OS name + version
Dataset source
local-demo / local-user (and the file’s format) / remote server / link (which provider) / Python or R viewer
Dataset identity
dataset id (
uns['cellucid']['id']in the file, or the id its route gives it; the Dataset box shows the name, not the id)
Why this matters:
the same bug can be caused by CORS in one environment and by caching in another.
Step 2: Turn on debug logging (recommended)#
App-wide debug (recommended default)#
localStorage.setItem('CELLUCID_DEBUG', 'true');
location.reload();
This enables:
the logger in
cellucid/assets/js/utils/debug.jsmany
[Main],[UI], and source-selection logs
Analysis debug (only when debugging analysis)#
Either:
add
?debug=1to the URL, orset
localStorage.setItem('debug', '1')and reload
This enables debug utilities in:
cellucid/assets/js/app/analysis/shared/debug-utils.js
Step 3: Identify the subsystem (pick one)#
Use the symptom list to choose your debugging path:
Symptom |
Subsystem |
Go to |
|---|---|---|
Blank page / module load error |
Boot / deployment |
“Boot issues” |
Dataset does not load / loads forever |
Data loading |
“Data loading issues” |
Field selector empty / wrong colors |
Fields + legends |
“Fields/legend issues” |
Filters don’t behave / count wrong |
Filters + visibility |
“Filtering issues” |
Selection/highlights wrong |
Highlights + tools |
“Highlight issues” |
Switching a view’s embedding breaks |
Dimension manager + viewer positions |
“Embedding switching issues” |
WebGL context lost / slow |
Rendering/GPU |
“Rendering issues” |
Session save/load wrong |
Session serializer |
“Session issues” |
Undo misses a change, or a step reads “Changed outside the history” |
Actions + history |
“History issues” |
Analysis panel broken |
Analysis module |
“Analysis issues” |
Figure export broken |
Figure export |
“Export issues” |
Community annotation broken |
GitHub auth/sync |
“Community annotation issues” |
Boot issues (page won’t start)#
Symptom: “Module script failed to load”#
Likely causes (ordered):
You opened via
file://instead of HTTP.Server sends wrong MIME type for
.js.Deployed under a subpath and module URLs don’t resolve.
How to confirm:
DevTools → Console: look for the module load error.
DevTools → Network: click the failing
.jsrequest and check headers.
Fix:
Run
python -m http.serverfromcellucid/(see Local development setup).Fix server MIME types (see Build, run, and deployment).
Symptom: “WebGL2 is required but not supported”#
Likely causes:
WebGL disabled by browser or corporate policy
remote desktop environment without GPU support
Fix:
Try another browser/machine; confirm WebGL2 is enabled.
Data loading issues#
Symptom: “Dataset loads forever”#
Likely causes (ordered):
CORS/mixed-content blocked a fetch.
Wrong path, and the UI is waiting on a fetch that never completes.
A large read on a slow host, or a file not written gene-major read over a slow link (the
[cellucid:read]console lines show what is still reading).
How to confirm:
Read
document.documentElement.dataset.startupPhase. Startup publishes the step it is in —sign-in,health,catalog,ui,dataset,positions,session, thenready— so a page that never finishes says where it stopped. The interface (ui) is built before the startup dataset loads; a dataset chosen in the data controls while it loads replaces it, and startup then publishesreadywithout it.The request/response steps (
sign-in,health,catalogandsession, which reads the starting view a sample’s catalog names) each have a 60-second deadline. A server that never answers ends startup on the startup-failure page, naming the step and the URL it was waiting on.dataset(opening the startup dataset: its metadata, field lists and the embedding its first view draws) stalls only when nothing at all has arrived for 60 s; every request it makes is retried or fails on its own, naming its URL, and the error names the reads that were open.positionsanduihave no deadline, because their time grows with the dataset; a slow but progressing load is not a stall.Network tab: which request is pending/blocked?
Console: enable
CELLUCID_DEBUGand look for the last “loading…” log.
Fix:
For a large cell-major (CSR) h5ad, the first gene waits for the gene index (watch the
[cellucid:read]console lines); this is the same opened in the browser or served by cellucid-python.For GitHub datasets, verify raw URLs are reachable.
For CORS, run the local app and connect from the same origin when possible.
Symptom: “Unexpected token <” during load#
Likely cause:
the server returned an HTML document for a JSON or binary request.
Fix:
Open the failed request URL in a new tab; verify it’s real JSON/binary and returns correct status codes.
Read the failure message before theorising#
Four distinct load failures used to report themselves identically. They no longer do, and the wording identifies the cause:
Message contains |
Cause |
Error code |
|---|---|---|
“was cancelled” |
An abort — re-thrown untouched, never relabelled |
(the original |
“is longer than the longest text this browser can hold” |
A metadata document exceeds the engine’s longest string |
|
“response body transfer failed” |
The connection dropped mid-body |
|
“must contain valid JSON” |
Genuinely malformed content |
|
The connection and catalogue UI classifies these by error code and HTTP status, never by matching message text, so a message you rephrase will keep its user-facing sentence — but a code you get wrong will silently move the failure into the wrong bucket. Set the code deliberately.
Deep dive:
Fields/legend issues#
Symptom: “Field selector is empty”#
Likely causes (ordered):
The file’s
obshas no columns (or none Cellucid can show).The launch defaults list only some columns (
uns['cellucid']['defaults']['obsKeys'], orcellucid serve --obs-key).You are in a snapshot view with a different view context.
How to confirm:
Read the file with anndata (
adata.obs.columns).Console: check
window._cellucidState.pointCountand whether a field loader exists.
Fix:
Add the columns to
obsand save the file again (or write it withprepare(obs=...)).Ensure the correct dataset is open.
Symptom: “Colors look wrong / legend doesn’t match”#
Likely causes:
field change didn’t trigger a color recompute
colormap override or category rename/delete registry is involved
How to confirm:
Subscribe to
field:changedevents and see what fires when you change a field.Inspect
window._cellucidState.getActiveField()in console.
Fix:
Ensure state owns the recompute and calls
viewer.updateColors(...).
Filtering issues#
Symptom: “Filter count changes but points don’t”#
Likely causes (ordered):
transparency/visibility updated in state but not pushed to the viewer;
it was pushed, but the alpha bytes did not change, so the upload was correctly skipped — the count you are reading is derived from something else;
the change is below the visible-alpha threshold, so it rounds to the same byte.
How to confirm:
Put a breakpoint in
viewer.updateTransparencyand look at its return value: it reports whether the published alpha generation actually moved.falsemeans the bytes were identical, and no upload was needed.Check
window._cellucidState.categoryTransparencylength and values.
Fix:
Ensure the filter manager calls
viewer.updateTransparency(...)(state→viewer sync is state-owned).If the return value is
falsebut you expected a change, the bug is upstream in how the alpha array was derived, not in the upload.
Highlight issues#
Symptom: “Highlighted cells disappear after filtering”#
Clarify expected behavior:
Highlights are stored independently of visibility; filtering hides points but does not remove them from highlight groups.
If behavior differs:
Confirm whether you are looking at “visible highlight count” vs “total highlight membership”.
How to confirm:
Check highlight pages and group membership in
window._cellucidState.getHighlightPages().
Embedding switching issues#
Symptom: “The picker or key 1/2/3 changes, but not the embedding”#
Likely causes (ordered):
The embedding is not turned on (Session › Data mapping), or the dimension has none.
Fetch failed (network/CORS), or its values were refused (NaN, Infinity, no variation).
Positions buffer length mismatch caused viewer to reject it.
Another view was selected: the picker and keys edit the selected view.
How to confirm:
Network tab: embedding fetches.
Console: look for
HighPerfRenderer updatePositions expected … floats, received ….
Fix:
Verify dataset identity + embeddings metadata.
Validate positions array length before calling
viewer.updatePositions.
Rendering issues (GPU / performance)#
Symptom: “WebGL context lost”#
Likely causes:
GPU memory pressure, especially with smoke or overlays on large datasets, or a very large highlight group (highlight geometry costs bytes per selected cell per view).
How to confirm:
DevTools console logs mention context loss.
Fix:
Reduce dataset size and reproduce.
Disable smoke/velocity and re-test to isolate.
Clear highlights and re-test.
Deep dive:
Symptom: “Frames got slower and I cannot see why”#
Start from the baseline: an idle frame uploads nothing. If your change made frames slower, the most likely cause is that you put work on the draw path without a gate.
How to confirm:
Open the Benchmark panel once, so the harness module is published on
window._cellucidBenchmarkHarness.Build a harness against the live viewer and canvas. It shadows the GL context and counts, per frame: buffer allocations and bytes, sub-updates and bytes, texture uploads and bytes, draw calls, sync stalls, and
getError/finish/flush/readPixelscalls.Hold the camera still. Every upload counter should be zero. A non-zero count while idle is the bug. Two frames after the last change a static scene stops drawing entirely, so draw calls fall to zero as well; if they do not, something keeps invalidating the frame or reports an animation (see the frame gate in Rendering pipeline (WebGL) and performance notes).
Then move the camera. Element-buffer uploads should appear only when the admitted node set changes, not on every frame.
Fix:
Gate the new work behind a dirty flag, a cache comparison, or the allocation watermark, following the three gates described in Rendering pipeline (WebGL) and performance notes.
Note
The headline FPS and frame-time tiles in the benchmark panel are CPU wall-clock measurements between frames, so they include everything else the tab is doing. Only Analyze performance uses real GPU timer queries, and only where the driver provides the extension. Do not attribute a frame-time change to the GPU on the strength of the headline tiles alone.
Symptom: “A frame rate measurement is not reproducible”#
Check, in this order, that between the two runs you did not change: the window size, the layout (one view / two- or three-view row / 2×2 grid — these are three different workloads, not one curve), the filter state (hidden cells are rejected before rasterisation and cost nothing), or the machine’s background load. See Benchmarking methodology and metrics.
Session issues (save/load)#
Symptom: “Session loads but key state is missing”#
Likely causes:
the feature is intentionally not kept (a device preference, community annotation state, a selection not confirmed)
the state is not part of any state domain, so no step and no session holds it
the one awaited load is still reading its lazy chunks
the dataset identity, a missing field or a damaged file refused the load and rolled it back
How to confirm:
Require terminal Session fully restored.
Check the exact Console error and progress failure; there is no skip/partial mode.
In the console, compare what the domains hold now with what you expect:
(await import('/assets/js/app/actions/index.js')).getStateDomains().owner('<domain>').capture().
Fix:
Confirm you loaded the same dataset id/fingerprint.
If the feature should persist, make it a state domain’s value (or part of one) with capture, restore and round-trip tests (Action layer, state domains and history, “How to add a piece of state”).
Deep dive:
History issues (undo, redo, steps)#
Symptom: “Undo does not put something back”#
Likely causes:
the action that changed it does not list that domain in
touches, so the history did not capture it before and aftera control changed state without dispatching an action (a mutating state call from a UI module)
How to confirm:
window._cellucidHistories.current().activeis the tracker in use; itsstepsmap holds each step’stouches,beforeandafter.A step labelled Changed outside the history (type
history.outside-change) names the domains that changed without an action; the console warns[history] <domains> changed outside an action.The area’s browser spec proves
touches(applyUndoRedo,tests/browser/helpers/state-history.mjs): run it for the action.
Fix:
Make the control dispatch the action (
dispatchFromControl), or record its change by observation (recordObserved), and list every domain the handler can change intouches(Action layer, state domains and history, “How to add an action”).
Symptom: “An action is refused with ‘… is not available on this page yet’”#
The type has no implementation now (getActions().unprovided() lists them):
its owner module has not started (a panel never opened, no dataset) or was
torn down. Provide it where the owner starts and remove it where it ends.
Analysis issues#
Symptom: “Analysis panel is blank”#
Likely causes:
analysis init threw
Plotly load blocked
How to confirm:
Check
window._comparisonModuleexists.Console error stack trace.
Deep dive:
Export issues#
Symptom: “SVG export freezes”#
Likely cause:
Full vector export on too many points.
Fix:
Use optimized-vector or hybrid.
Deep dive:
Community annotation issues#
Symptom: “Sign-in succeeds but repos list is empty”#
Likely causes:
GitHub App not installed on the org/repo
wrong user signed in (multiple accounts)
How to confirm:
In the UI: which GitHub user is shown?
Confirm app installation on the target repo owner.
Fix:
Install the GitHub App on the repo owner.
Reconnect and Pull.
Symptom: “Editing in two tabs causes disconnect”#
This is intentional:
a scope lock prevents silent data loss from concurrent edits.
How to confirm:
You’ll see a notification that the lock was lost.
Fix:
Close extra tabs for the same dataset/repo/user scope.
Symptom: “Pull warns that files were left out”#
This is intentional: a Pull leaves a file that fails the annotation contract
out on its own (verdictOf in github-sync.js) instead of stopping, and keeps
the reason in the file cache by blob id.
How to confirm:
The warning’s details list each file with its reason.
python scripts/validate_user_files.pyin the annotation repository reports the same files.
Fix:
Fix or remove the files on GitHub; the next Pull reads only what changed.
Symptom: “… runs an older version (contract N) than this Cellucid”#
The deployed Worker’s root document names an older contract than
WORKER_CONTRACT_VERSION (community-annotations/worker-contract.js).
Deploy the Worker from the same source as the page
(cellucid/docs/github-oauth-cloudflare-setup.md).
Developer references:
cellucid/assets/js/app/community-annotations/REPO_SETUP.mdcellucid/assets/js/app/community-annotations/scope-lock.js
Next: Testing, CI, and release process (how to validate changes before sharing).