Skip to content

Repository files navigation

image-pump

CI npm license

A framework-agnostic progressive responsive-image loading orchestrator for the browser.

It coordinates loading of the same image in multiple widths (a "width ladder", the srcset sizes your image backend produces) so that every consumer gets the best width already available — never a duplicate request, never a downgrade. The mechanics:

  • a concurrency limit (maxActivePumps) and an internal load queue,
  • subscriptions ("I need image X at width W, notify me when usable"),
  • fallback / upgrade semantics over the width ladder — a subscriber may be temporarily served a smaller width, then upgraded, but never downgraded,
  • request deduplication: one image × one width = at most one network request,
  • backpressure: after a slow load the pump idles its slot for a couple of frames, so upgrade bursts cannot starve not-yet-visible images.

Zero runtime dependencies. TypeScript, dual ESM/CJS build.

Contents

Motivation

Typical setup: an image backend (imgproxy & friends) can serve each attachment in any width on demand — the width is encoded right in the URL processing options — so your app defines a fixed ladder of widths (100, 200, … 800). A UI — gallery grid, lightbox, carousel — renders many images at once, and each rendered box may need a different width. Naive <img src> per box causes:

  • duplicate requests for the same image at different widths,
  • visible images competing with offscreen ones for the connection pool,
  • janky upgrades when a bigger width replaces a smaller one mid-view.

ImagePump is the single point that owns "which (image, width) pairs are being loaded / loaded / failed" and decides what to fetch next. The component layer stays declarative: subscribe to what you need, render what you get.

Lazy loaders trigger requests, framework image components generate markup, Suspense image caches deduplicate a single source — but none of them orchestrates many consumers of the same image across a width ladder; filling that gap is the reason ImagePump exists (see Related libraries for the landscape).

Why not native srcset/sizes?

The browser already picks a width from a srcset, prioritizes the fetch and keeps it in the HTTP cache — for markup you control end-to-end, prefer it. ImagePump earns its keep only when the consuming side is JavaScript:

  • you need the loaded src itself — drawing to a <canvas>, using it as a CSS background from JS, feeding it to WebGL — srcset never tells you which URL it chose;
  • many components share one image across widths — a gallery card at 100px and a lightbox at 800px want one shared load budget, deduplication and upgrade policy, not two independent <img> decisions;
  • you need delivery guarantees — the no-downgrade rule survives DOM updates, re-subscribes and queue rotation; srcset re-evaluation on resize promises nothing like it;
  • you need to program the queue — concurrency limits, backpressure, prefetching the next carousel slide are not expressible in markup.

If none of these apply, stay with plain srcset — ImagePump will not beat the browser's own scheduler.

Core model

ImagePump
├── state: Map<imageId, { [widthKey]: { src, status, subscriptions } }>
├── subscriptions: Array<{ imageId, image, width, widthKey, callback }>
└── activePumps: number
  • image — your domain object (e.g. { attachmentId, basename, extension }).
  • imageId — a stable cache key derived by createImageId (see Design notes for what to include — and what to deliberately exclude).
  • width ladder — the ascending list of widths your backend can produce. Validated at construction time (dev mode only).
  • status per (imageId, width): pending → loading → loaded | failed (failed is terminal — guarantee 5).

Subscribe flow

  1. subscribe(image, width, callback) registers a subscription and creates the (imageId, width) state slot if absent (src is computed eagerly via createImageSrc).
  2. If a free pump slot exists and the load is actually needed (needsLoad), loading starts immediately; otherwise the subscription sits in the queue and is picked by getNextLoad() when a slot frees up.
  3. On success, publishSuccess notifies the image's subscribers with the loaded src — respecting the no-downgrade rule (see below). On failure, onFailure subscribers are notified the same way.
  4. The returned function unsubscribes; it is idempotent (double call is safe).

needsLoad — when a request is skipped

A load for (imageId, width) is not started when this width or any width above it is already loading or loaded (loading 400 → subscribing to 100 issues no request; the same (imageId, width) subscribed twice → one request), and a failed width is never retried.

getAnyLoaded — synchronous lookup

getAnyLoaded(image, width?, onlyLoaded?) returns the best already-known src without waiting:

  • exact width first;
  • then the closest loaded width above (an acceptable upgrade), then below;
  • then, unless onlyLoaded, the closest loading width above/below — useful to render something that will be in cache soon;
  • width: undefined → the closest loaded/loading width from the top.

No-downgrade rule

A subscriber's rendered width may only grow: when a width finishes loading, subscribers that already hold an equal-or-bigger loaded width are not notified — whichever order the widths resolved in.

Backpressure pause

When a load takes longer than IMAGE_PUMP_AFTER_LOAD_DELAY (2 frames at 60 FPS ≈ 34 ms), the pump keeps its slot busy for the same delay before taking the next queued load. This prevents a cascade of upgrades from monopolizing the channel right after a slow response.

Guarantees (the contract, enforced by the spec)

  1. maxActivePumps concurrent loads at most; the queue drains automatically as slots free. A slot is always released (try/finally), so no failure path can permanently shrink the concurrency budget.
  2. One network request per (imageId, width); duplicates are never issued — including the "smaller width while a bigger one is loading" case.
  3. Never downgrade a delivered source; never re-notify subscribers that already hold an equal or bigger loaded width.
  4. unsubscribe is idempotent; a load completing after full unsubscribe is a no-op (does not throw).
  5. failed widths do not restart on re-subscribe.
  6. Subscriptions sharing the same imageId but different image payloads (e.g. different extensions) share one cache slot; the first started source wins for everyone (see Design notes).
  7. Subscriber callbacks (callback and onFailure) are isolated: one throwing cannot break other notifications or the pump.
  8. A pending slot that loses its last subscriber is garbage-collected — only subscriptions trigger loads, so the slot is unreachable; loaded slots stay as a cache, failed slots stay to keep guarantee 5.
  9. dropState(image) wipes the image's state and live subscriptions; in-flight loads finish silently, stale unsubscribe functions become no-ops.

Installation

npm install image-pump
# or
yarn add image-pump

Quick start

import { ImagePump } from 'image-pump'

interface IAttachment {
  id: string
  basename: string
  extension: 'jpg' | 'webp' | 'avif'
}

const widths = [100, 200, 300, 400, 500, 600, 700, 800] as const

const pump = new ImagePump<IAttachment, typeof widths[number], Omit<IAttachment, 'extension'>>({
  maxActivePumps: 3,
  widths,
  // The cache key deliberately omits `extension` — see Design notes.
  createImageId: ({ id, basename }) => `${id}/${basename}`,
  createImageSrc: ({ id, basename, extension }, width) =>
    `/api/attachments/${id}/${basename}/${width}.${extension}`,
})

// Somewhere in a component:
const unsubscribe = pump.subscribe(attachment, 400, (src) => {
  imgElement.src = src // may first receive a smaller width, then be upgraded
})
// on teardown:
unsubscribe()

// Synchronous "what do we already have?":
const known = pump.getAnyLoaded(attachment, 400)

API

new ImagePump<I, W, ID>(options)

Option Type Notes
maxActivePumps number Concurrent load limit (queue drains automatically)
widths readonly W[] Ascending width ladder; validated in dev mode
createImageId (image: ID) => string Stable cache key
createImageSrc (image: I, width: W) => string URL factory
loadTimeoutMs number Stall kill-switch: fail a load after this many ms; 0 (default) disables

Type parameters: I — full image identity used to build a src; ID — identity used for the cache key (defaults to I). Splitting them lets several src variants (formats) share one cache slot.

Instance members

  • subscribe(image, width, callback, options?) — returns the unsubscribe function. Options:
    • immediate: true — deliver the best already-known source synchronously at subscribe time (without it a subscription only hears about loads that finish later);
    • onFailure — (failure: { source, width }) => void, notified when a candidate width fails while this subscription could have received it, under the same no-downgrade rules as success (e.g. LQIP fallbacks).
  • prefetch(image: I, width: W): void — warm the cache without keeping a subscription (hover-preloads, next carousel slides).
  • getAnyLoaded(image: I, width?: W, onlyLoaded?: boolean): string | undefined
  • dropState(image: ID): void — wipes the image's state and live subscriptions; in-flight loads finish silently.
  • createImageSrc(image: I, width: W): string — exposed factory
  • state — read-only introspection: ReadonlyMap<imageId, { [width]: { src, status, subscriptions } }>
  • subscriptions — read-only live subscription list
  • activePumps: number

The package also exports the EImagePumpSourceStatus const (the state statuses), the createImagePromise default loader and all option/subscription types.

Recipes

React hook (canonical usage from the project of origin)

import { useEffect, useMemo, useState } from 'react'

export function useImagePumpedSource(
  pump: ImagePump<IAttachment, ImageWidth, Omit<IAttachment, 'extension'>>,
  image: IAttachment | undefined,
  width: ImageWidth | undefined,
): string | undefined {
  const [source, setSource] = useState<string | undefined>(
    () => image ? pump.getAnyLoaded(image, width) : undefined,
  )

  useEffect(() => {
    if (!image) return
    if (!width) {
      setSource(pump.getAnyLoaded(image, undefined))
      return
    }
    // immediate: true re-delivers the best known source on every width change
    return pump.subscribe(image, width, setSource, { immediate: true })
  }, [pump, image, width])

  return source
}

Details worth copying:

  • initial useState seeds synchronously from getAnyLoaded — the first paint can already use a pumped (possibly smaller) width instead of a spinner;
  • immediate: true re-delivers the best known src on every width change, and later loads upgrade it through the same setSource callback;
  • the effect returns the unsubscribe function directly.

Prefetching

const startHoverPreload = () => pump.prefetch(nextAttachment, 800)

The load starts immediately (subject to the shared concurrency budget) and the loaded slot stays in the cache for the actual subscription — no bookkeeping to undo. A prefetch of a width that is already superseded by a bigger loading one leaves no trace at all.

Rendering without a pump (SSR, first render, bots)

When there is no time to wait for subscriptions — the server, a bot, the very first render — fall back to the URL the cache would eventually hold, or an LQIP:

const src = isBot
  ? pump.createImageSrc(image, width)
  : pump.getAnyLoaded(image, width, true) || pump.createImageSrc(image, width)

onlyLoaded: true avoids handing the browser a loading src that may never finish before hydration.

Design notes

createImageId deliberately omits the image format

One attachment + width = one cache slot, regardless of the requested format (webp/avif/jpg). The first started format "wins": its src is written into the state and reused for every later subscription to the same imageId + widthKey. This is acceptable when the format is stable within a session (server-side negotiation via SSR/cookies), so a duplicate download of another format is never needed. A test pins this behavior.

Why loading counts as "good enough" in getAnyLoaded

A loading src will be in the HTTP cache by the time it finishes, so pointing an <img> at it merely defers the decode, not the network. Pass onlyLoaded: true to opt out.

Callbacks are untrusted, errors are isolated

Consumer callbacks (callback, onFailure) can throw without breaking neighboring notifications or the pump's accounting; the load path releases its slot in a finally, so no failure — a rejected custom loader included — can permanently shrink the concurrency budget.

Queue ordering

getNextLoad currently picks the first subscription that still needs a load (FIFO by subscription order). Prioritization (visible-first, recency/LIFO) is the main roadmap item — the call site is a single method.

Related libraries

Each of these covers one axis of ImagePump — none combines a width ladder, a shared concurrency budget, per-(image, width) deduplication and no-downgrade delivery:

  • unpic-img — the standard multi-framework <Image> for CDN URLs: srcset/sizes markup, browser scheduling; no orchestration for JS consumers.
  • lazySizes + RIAS/optimumx — substitutes a {width} placeholder in the URL with the measured element width; per-element, so no shared budget, no upgrade policy, no programmatic src.
  • PixiJS Assets and PreloadJS LoadQueue — asset queues with a concurrency limit; they load lists of URLs, not "the best width for each subscriber".
  • Cornerstone.js progressive loading — smaller/lossy first, then an upgrade, over a priority pool: the same semantics, but inside a DICOM-viewer stack.

URL builders such as @imgproxy/imgproxy-js-core compose with ImagePump (createImageSrc can delegate to them) instead of competing.

Rule of thumb: markup consumers — srcset + a URL builder or unpic-img; JavaScript consumers sharing one image across widths — that is the gap ImagePump fills.

Development

The Guarantees section is mirrored one-to-one by src/ImagePump.spec.ts — when observable behavior changes, update both. The development setup, the full verification chain (lint → typecheck → test → build → dist checks), the contribution conventions and the release process are described in CONTRIBUTING.md. User-facing changes are tracked in CHANGELOG.md.

Roadmap

  • Retry strategy for failed widths (today failed is terminal); it should distinguish network failures from decode() failures, which are often transient.
  • Queue prioritization in getNextLoad (visible-first / recency).
  • Configurable loader (replace the default createImagePromise, e.g. for fetch + blob URLs or canvas-driven sources; fold in crossOrigin / fetchPriority knobs — the timeout and error contract already exist).

License

MIT

About

Progressive responsive-image loading orchestrator: width ladder, concurrency limit, subscriptions, fallback upgrades without downgrades

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages