pulscheck v0.1.0 — findings
A short, factual write-up of what was built, what was kept, what was cut, and where the gaps are.
1. Problem
A class of frontend bugs lives in the ordering of async events, not in any line of source code:
- Stale fetch response overwrites fresh data (typeahead)
- Timer/listener/fetch survives the component that owned it
- Same handler fires twice within milliseconds
- Promise chain starts and never finishes
These bugs don't throw. Sentry doesn't fire. Linters run pre-execution. AI assistants pattern-match source. Playwright runs on deterministic CI networks where the 3G-phone race doesn't reproduce. Chrome DevTools shows a timeline but a human has to spot the pattern. The gap was a runtime-layer observer that watches how async events relate to each other.
2. Method
- Instrument 8 globals:
fetch,setTimeout,setInterval,clearTimeout,clearInterval,addEventListener,removeEventListener,WebSocket. - Record each call as a timestamped event with
file:lineextracted fromError.stack. - Store in a ring buffer (O(1) insertion, 10k events, configurable).
- Analyse with 4 pattern detectors over the observed event order.
- Dedupe findings via structural fingerprinting — one finding per bug, not per occurrence.
- Supplement with 1 static AST rule for a common pre-runtime pattern.
Test coverage: 105 unit tests, all green. Typecheck + build clean.
3. What worked
| Detector | Catches | Confidence |
|---|---|---|
| after-teardown | timer/listener/fetch alive after scope end | high |
| response-reorder | stale response resolves last and overwrites fresh | high (per-endpoint generation tracking lifts warning → critical when sink confirmed) |
| double-trigger | same action fires twice within ms | medium-high |
| dangling-async | chain started but never completed | medium |
fetch-no-abort-in-effect (AST) | fetch() inside React effect without AbortController wired into cleanup | high, but closure-walking is conservative → false positives when cleanup is indirect |
Call-site attribution — file:line of both sides of the collision — is the single most useful piece of output. It collapses debugging from "I think there's a race somewhere" to "these two lines collided."
4. What was cut
- Detector count: 7 → 4. Three detectors had fingerprints that overlapped existing ones. They generated noise without catching a distinct class. Removed.
- Timer/listener leak detection (static).
@eslint-react/eslint-plugin'sno-leaked-timeout,no-leaked-interval, andno-leaked-event-listeneralready cover this. Re-implementing would have been duplication. Only the one AST rule with no good alternative (fetch-no-abort-in-effect) was kept. - Source-map-based file mapping at runtime. Too expensive. Stack-trace-derived
file:lineis the minimum-viable path and good enough in practice.
5. What didn't work
pulscheck scan --fail-on <level>silently exits 0. Onlypulscheck ci --fail-onexits non-zero. The flag is accepted on both commands but honoured on one. Documented workaround: useciin pre-commit hooks. Proper fix queued for v0.1.1.- Publish via GitHub Actions. Failed three times before v0.1.0 shipped. Root cause: email-OTP 2FA on a brand-new npm account is insufficient for first-publish, even with
--provenanceand OIDC. Shipped from terminal with a granular token withbypass 2FAset. Future releases need the token swapped into theNPM_TOKENGH secret. - Runtime cost under heavy load. Unmeasured. The ring buffer is O(1) but every patched call adds a stack-trace capture, which is not free. Dev-only usage sidesteps this; production use is explicitly unsupported in v0.1.0.
6. Comparison
| Tool | When it runs | What it sees | What it misses |
|---|---|---|---|
| ESLint / TypeScript | Pre-execution | Source text | Everything temporal |
| Copilot / Cursor | Pre-execution | Source text | Everything temporal |
| Sentry / Datadog RUM | Post-execution | Thrown exceptions | Silent data corruption (stale responses, dropped updates) |
| Chrome DevTools Performance | During execution | Full timeline | Patterns — a human must spot them |
| Playwright / Cypress | CI | E2E outcomes under deterministic network | Races that only happen on real-world variable-latency networks |
| TanStack Query / SWR | During execution | Request lifecycle of requests it manages | Requests outside its wrapper; legacy code; raw fetch |
| pulscheck | During execution | Relationships between all 8 async primitives | Races not expressible as one of the 4 patterns; production (dev-only) |
The nearest neighbour is TanStack Query — it eliminates the same bug class by construction (prevention) while pulscheck detects after the fact. If an app uses Query with correct cache keys and AbortController everywhere, pulscheck's runtime findings should approach zero. That is the expected outcome, not a failure of either tool.
7. Gaps
- Race shapes outside the 4 patterns. Cross-tab races via
BroadcastChannel/SharedWorker;postMessageordering; WebRTC data channels; Web Locks API contention. None are instrumented. - Non-global async primitives. Custom schedulers (React's own, RxJS
Scheduler,requestIdleCallback) are not patched. - iframe / ShadowDOM boundaries. Events crossing boundaries aren't correlated.
- Production sampling. No plan yet for low-overhead production tracing.
8. Integration outlook
The frontend observability stack is fragmented: Sentry for exceptions, Playwright for e2e, ESLint for static, DevTools for interactive inspection. Runtime race detection doesn't need a parallel standalone tool — the value compounds where developers already are.
- Sentry. A "race detected" event type alongside exceptions fits the existing mental model (error → stack → fix). The fingerprinting logic already produces a stable ID suitable for Sentry's grouping. Integration cost: low.
- Playwright. Running
devMode()during e2e tests catches races in realistic scenarios — Playwright's route interception can inject latency to surface them. Turns a missed race into a test failure. Integration cost: low, user-side (write a fixture). - React DevTools. Scoped-effect hooks already track component lifecycle; exposing findings in a DevTools panel tied to component trees closes the "where in my tree" question. Integration cost: high (requires DevTools extension work).
v0.1.0 is a standalone library because that is the minimum ship. Long-term, a @sentry/pulscheck integration or a Playwright fixture likely does more for users than a separate dashboard.
9. Time-saving hypothesis (unvalidated)
The claim "this saves debugging time" is not yet evidence-backed. At v0.1.0 it is a hypothesis:
When a race finding surfaces with
file:lineof both sides, the bisection phase of debugging ("where is this happening") is skipped.
Falsification conditions:
- False-positive rate high enough that developers disable the tool.
- Finding rate so low that cost of running exceeds debugging time saved.
- Findings point to the wrong
file:linebecause stack-trace extraction hits a bundled/minified frame.
v0.1.0 is the measurement instrument. The 90-day post-launch window (stars, issues filed, "it caught X" reports) is the data.
10. Conclusion
- Covers one gap (runtime race detection) that existing tools do not.
- Redundant against prevention-layer tools (TanStack Query) by design; the redundancy is expected.
- Useful primarily in mixed/legacy codebases and for reproducing suspected-but-unconfirmed flaky races.
- Long-term value is probably as an integration (Sentry / Playwright / DevTools), not a standalone.
- Four runtime detectors + one static rule is the deliberate scope — smaller than originally drafted, after overlapping detectors were cut.
Whether the tool is worth keeping alive is an empirical question with a 90-day answer window.