npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zakkster/lite-inp

v1.3.1

Published

Zero-GC INP attribution via Event Timing API + Long Animation Frames. Preallocated struct-of-arrays for interactions and LoAF entries. The observer callbacks write to typed arrays, not objects.

Readme

@zakkster/lite-inp

npm version Zero-GC sponsor npm bundle size npm downloads npm total downloads TypeScript Dependencies License: MIT

Zero-GC INP attribution. The observer callback writes to typed arrays, not objects.

Interaction to Next Paint is a Core Web Vital. Attribution is genuinely hard. The irony of most monitoring libs is they allocate inside the observer -- GC pauses from the monitoring tool become part of the metric. This library uses preallocated struct-of-arrays for both interactions and LoAF entries so the observer callbacks create nothing on the heap.

npm install @zakkster/lite-inp

Quick start

import { createInpObserver } from '@zakkster/lite-inp';

const inp = createInpObserver({
    // Hot path -- fires on every new-worst interaction. Zero allocation.
    // `entry.attribution` is null here so the observer callback does not
    // allocate. Copy the primitive fields you need before onUpdate returns.
    onUpdate(entry) {
        myMetric.update(entry.duration, entry.eventType);
    }
});

// Cold path -- call when you're ready to report the metric. This allocates
// the full entry object with LoAF attribution (top-3 scripts, invoker,
// sourceURL, sourceFunctionName).
addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
        const current = inp.getINP();
        if (current) sendBeacon('/inp', JSON.stringify(current));
    }
});

What it does

  1. Event Timing observer (type: 'event', buffered: true) captures every interaction with interactionId > 0. Multiple events per interaction (pointerdown + pointerup + click) are collapsed to the max duration. Phase breakdown (input delay, processing time, presentation delay) is computed from startTime, processingStart, processingEnd, and duration.

  2. Long Animation Frames observer (type: 'long-animation-frame') captures LoAF entries with script attribution (invoker, sourceURL, sourceFunctionName, duration). Top-3 scripts per LoAF are retained.

  3. INP computation correlates the p98 worst interaction with every LoAF overlapping its unquantized processing window (processingStart..processingEnd), up to 4, plus the element target and the dominant phase -- giving you script- and element-level attribution for why the interaction was slow.

Zero-GC hot path

The observer callbacks write numeric fields directly into preallocated Float64Array / Int32Array ring buffers. Event type strings are interned to integer IDs (one-time allocation per unique type). No intermediate objects, no Array.push, no string concatenation per event.

The only allocation per unique interaction is a single Map.set(interactionId, slotIndex) call. Interactions are user-initiated (clicks, keystrokes) -- maybe 1-5 per minute. This is not frame-rate allocation.

The onUpdate callback receives the reusable entry with primitives populated and attribution set to null. LoAF attribution is a cold-path concern -- computing it means walking the LoAF ring and allocating a scripts array -- so the hot path skips it entirely. If you need attribution for the current INP, call getINP() from inside onUpdate (or on any cadence you prefer); that's the explicit cold-path allocation.

This split is deliberate: measuring INP shouldn't itself cause GC pauses that perturb what you're measuring.

Cold-path methods (getINP(), getInteractions(), getLoafs()) allocate result objects freely. They're called on demand, not per event.

API

createInpObserver(options?)

| Option | Type | Default | Description | |--------|------|---------|-------------| | interactionCap | number | 512 | Max unique interactions tracked (ring, power-of-two) | | loafCap | number | 64 | Max LoAF entries retained | | durationThreshold | number | 16 | Min event duration (ms) reported by Event Timing | | onUpdate | function | null | Called when a new worst interaction is recorded (hot path; entry.attribution is null -- call getINP() for attribution) |

InpObserver

| Method / Property | Description | |---|---| | getINP() | Current INP entry with phase breakdown + LoAF attribution | | getInteractions() | All tracked interactions, sorted by duration descending | | getLoafs() | Recent LoAF entries with script attribution | | destroy() | Disconnect observers and reset all state; getters return null/empty afterward | | interactionCount | Number of unique interactions tracked | | currentINP | Current worst duration (ms) | | supported | True if Event Timing API is available | | loafSupported | True if LoAF API is available |

InpEntry

{
    duration: number;          // total interaction duration (8ms quantized)
    inputDelay: number;        // user input -> handler start
    processingTime: number;    // handler execution
    presentationDelay: number; // handler end -> next paint
    startTime: number;
    eventType: string;         // 'pointerup', 'keydown', 'click', etc.
    interactionId: number;
    // Populated only by getINP() (cold path). null when there is no interaction,
    // on the onUpdate reusable entry, and after getINPInto().
    attribution: {
        loafs: [{                        // up to LOAF_MATCH_CAP (4) overlapping
            startTime: number;           // LoAFs, earliest-start first; empty []
            duration: number;            // when no LoAF overlaps.
            blockingDuration: number;
            styleAndLayoutStart: number;
            scripts: [{
                invoker: string;
                sourceURL: string;
                sourceFunctionName: string;
                duration: number;
            }];
        }];
        target: string | null;           // interned 'tag#id' / 'tag.class' (NOT a
                                         // CSS selector); null when unknown, or
                                         // past the 128-target cap (fail closed).
        phase: 'processing' | 'presentation';  // 'presentation' when the paint
                                         // dominates (presentationDelay >
                                         // processingTime), else 'processing'.
    } | null;
}

Attribution correlates against the unquantized processing window (processingStart..processingEnd), collecting all overlapping LoAFs up to LOAF_MATCH_CAP = 4 (earliest-start first, the deterministic tie-break) -- not a single best overlap. Presentation-dominated interactions correlate against the next frame's style/layout segment (styleAndLayoutStart) instead of the processing scripts. target is interned through a WeakMap keyed by node identity, so the Node is never stored and detached subtrees stay collectable (no RUM leak); the intern map is bounded at 128 distinct targets and fails closed to null beyond it (null is not a wrong element). See the ADRs (decisions/0002-correlation.md, decisions/0003-target.md in the repository).

Browser support

| Feature | Chrome | Firefox | Safari | |---------|--------|---------|--------| | Event Timing (interactionId) | 96+ | 144+ | -- | | LoAF (script attribution) | 123+ | -- | -- |

The library feature-detects both APIs. If Event Timing is unavailable, supported returns false and no observers are started. If LoAF is unavailable, INP is still computed and getINP().attribution is still an object -- its loafs[] is simply empty, while target and phase are still set. attribution is null only when there is no interaction, on the onUpdate hot-path entry, and after getINPInto().

Firefox is tested, not just claimed. A repo-only lane (npm run test:browser-ff) drives the full differential corpus in real Firefox 153.0 (Playwright's bundled build) and measures the degraded-attribution story exactly:

  • INP is measurable and agrees with web-vitals within the 8 ms Event-Timing quantization on every scenario, wrap-ring included -- web-vitals runs under Firefox too, so the differential oracle is real there.
  • loafSupported === false: long-animation-frame is absent from supportedEntryTypes, so there is no script/LoAF attribution (attribution.loafs is empty).
  • Element attribution works: PerformanceEventTiming.target is exposed, so attribution.target resolves to a "tag#id" string (measured div#hotspot).
  • attribution.phase (processing vs presentation) is computed from the Event-Timing entry timings alone, so it works without LoAF.

So the Firefox story is: INP measurable, element target and phase attribution present, script/LoAF attribution absent (empty loafs) -- attribution is a non-null object, never null. The allocation gate is Chromium-only: it rides CDP's HeapProfiler, and Firefox speaks Juggler rather than the Chrome DevTools Protocol, so there is no HeapProfiler seam to sample there. The Firefox lane is a correctness/parity + graceful-degradation gate; it is not part of verify (no Firefox binary is guaranteed in every environment) -- run it via npm run verify:all.

INP calculation

INP is the p98 of the page's interactions: the worst interaction after skipping the floor(interactionCount / 50) slowest, or simply the worst for pages with fewer than 50 interactions. performance.interactionCount (when available) supplies the skip count, matching the spec definition.

Because INP is a page-lifetime percentile, the library maintains an independent longest-N list -- the 10 longest interactions seen since page load -- separate from the interactionCap recency ring. The percentile is computed from that list, so INP stays correct on long-lived pages (SPAs, dashboards) even after thousands of interactions have cycled through and been evicted from the ring. The recency ring is retained for getInteractions() detail. The longest-N list is maintained allocation-free on the hot path, and matches the estimator used by CrUX and web-vitals.

Differential oracle: parity with web-vitals

Same number, silent observer. lite-inp and web-vitals consume the same browser Event-Timing feed at the same durationThreshold, so any disagreement beyond the 8 ms Event-Timing quantization is a real algorithm difference. The browser lane (npm run test:browser, and the demo's scene 03) runs both side by side and asserts |lite-inp - web-vitals| <= 8 ms on every scenario -- including the ring-wrap catcher, where a recency-only estimator (the pre-1.1 bug) provably disagrees by more than 8 ms.

One gated run of the four playground injectors (the gate asserts the 8 ms bound on every run; absolute values are workload-dependent):

| injector | dominant phase | lite-inp | web-vitals | |delta| | |----------|----------------|---------:|-----------:|---------:| | sync block (320 ms) | processing | 328 ms | 328 ms | 0 ms | | rAF chain | presentation | 96 ms | 96 ms | 0 ms | | layout thrash | processing | 144 ms | 144 ms | 0 ms | | clean handler | -- | none | sub-threshold | agree (both none) |

Scene 01's attribution names the element that ran the interaction -- for the sync injector, attribution.target === 'button#inject-sync': the demo's own button.

Allocation honesty

The observer callback allocates nothing per interaction. The honest floor is the platform's own getEntries() array -- the browser allocates it, and a sampling profiler charges it to whichever JS frame called it. That is the irreducible cost of reading the feed, not an object we create.

| path | per-interaction allocation | whose | |------|----------------------------|-------| | observer callback (onEventEntry, maintainLongest, interning, longest-N) | 0 B | ours -- preallocated SoA, no object / push / concat | | performance ... getEntries() array | ~a few dozen bytes / callback | the platform's -- the floor | | Map.set(interactionId, slot) | one entry per unique interaction | ours -- user-initiated (clicks/keys), not per frame | | getINP() + .attribution | allocates, by design | ours -- cold path, on demand | | scene-04 allocating-onUpdate (the control) | ~0.5 MiB / fire (16384 small objects) | deliberately wrong -- the demo's visible climb |

Gated: the demo frame-loop's honest observer path sampled ~0 KiB over a 600-tap burst (gate floor 12 KiB), while flipping the scene-04 toggle made the same burst climb ~300 KiB on the control's own onUpdate frame (measured 280-340 KiB across eight back-to-back runs, against a derived floor of ~123 KiB) -- the control has teeth and the margin is deterministic. The climb is read on the control's allocation frame, not the whole-page total (which is high-variance sampling noise). The end-to-end overhead lane (npm run test:overhead) fingerprints a no-observer baseline page against an observed one over an identical burst and bounds the per-interaction delta.

License

MIT (c) Zahary Shinikchiev