Skip to content

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:line extracted from Error.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

DetectorCatchesConfidence
after-teardowntimer/listener/fetch alive after scope endhigh
response-reorderstale response resolves last and overwrites freshhigh (per-endpoint generation tracking lifts warning → critical when sink confirmed)
double-triggersame action fires twice within msmedium-high
dangling-asyncchain started but never completedmedium
fetch-no-abort-in-effect (AST)fetch() inside React effect without AbortController wired into cleanuphigh, 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's no-leaked-timeout, no-leaked-interval, and no-leaked-event-listener already 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:line is the minimum-viable path and good enough in practice.

5. What didn't work

  • pulscheck scan --fail-on <level> silently exits 0. Only pulscheck ci --fail-on exits non-zero. The flag is accepted on both commands but honoured on one. Documented workaround: use ci in 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 --provenance and OIDC. Shipped from terminal with a granular token with bypass 2FA set. Future releases need the token swapped into the NPM_TOKEN GH 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

ToolWhen it runsWhat it seesWhat it misses
ESLint / TypeScriptPre-executionSource textEverything temporal
Copilot / CursorPre-executionSource textEverything temporal
Sentry / Datadog RUMPost-executionThrown exceptionsSilent data corruption (stale responses, dropped updates)
Chrome DevTools PerformanceDuring executionFull timelinePatterns — a human must spot them
Playwright / CypressCIE2E outcomes under deterministic networkRaces that only happen on real-world variable-latency networks
TanStack Query / SWRDuring executionRequest lifecycle of requests it managesRequests outside its wrapper; legacy code; raw fetch
pulscheckDuring executionRelationships between all 8 async primitivesRaces 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; postMessage ordering; 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:line of 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:line because 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.

PulsCheck — originated by Oliver Nordsve, 2026