Lazy image loading with observability, retry resilience, and cross-framework consistency.
Built for production React and Angular apps — not just demos.
Demo · Core docs · React docs · Angular docs · Changelog
loading="lazy" is a good baseline. It does not tell you anything.
When a product image fails on a 3G connection — CDN timeout, bad deploy, misconfigured origin — the browser makes one attempt and stops. No retry. No callback. Nothing in Sentry or Datadog. You find out when a user complains or a sales report looks wrong.
| symptom | |
|---|---|
| Silent failures | A 404'd product image is invisible to your error monitoring. The user sees a broken icon. You never find out. |
| No visibility | Which images fail most? What is your p95 load time on 3G? Do users even scroll to image #5? You cannot answer these. |
| Inconsistent behavior | React and Angular apps in the same org each make their own image loading choices. No shared retry logic, no shared telemetry. |
LazySnap closes these gaps with a single install.
| Package | Description | Size |
|---|---|---|
@lazysnap/core |
Framework-agnostic engine. Zero dependencies. Works with any JS framework or none. | ~3kb |
@lazysnap/react |
<LazyImage /> component + useLazyImage() hook. Full TypeScript props. |
~2kb |
@lazysnap/angular |
lazysnap directive + LazysnapModule. Angular 15+, SSR-safe. |
~2kb |
# core — any framework
npm install @lazysnap/core
# react
npm install @lazysnap/react
# angular
npm install @lazysnap/angularimport { observe, configure, createAnalyticsPlugin, consoleHandler } from "@lazysnap/core";
// Wire up observability once at app startup
configure({
...createAnalyticsPlugin({ handler: consoleHandler }),
});
const cleanup = observe(document.querySelector<HTMLImageElement>("img#hero")!, {
src: "/images/hero.jpg",
placeholder: "/images/hero-lqip.jpg",
retries: 2,
onLoad: (entry) => console.log(`loaded in ${entry.timing.timeToLoad}ms`),
onError: (entry, err) => monitor.capture(err, { src: entry.options.src }),
});
cleanup(); // call on unmount / route changeimport { LazyImage } from "@lazysnap/react";
function ProductCard({ product }: { product: Product }) {
return (
<LazyImage
src={product.imageUrl}
placeholder={product.lqipUrl}
alt={product.name}
width={800}
height={600}
retries={3}
decoding="async"
loadedClassName="card__img--loaded"
fallback={<ProductSkeleton />}
onError={(entry, err) => monitor.capture(err, {
src: entry.options.src,
attempts: entry.attempts,
network: entry.connection.effectiveType,
})}
/>
);
}// app.module.ts
import { LazysnapModule } from "@lazysnap/angular";
@NgModule({ imports: [LazysnapModule] })
export class AppModule {}<img
lazysnap
[lazysnapSrc]="product.imageUrl"
[lazysnapPlaceholder]="product.lqipUrl"
[lazysnapRetries]="3"
[lazysnapRetryDelay]="1500"
(lazysnapLoaded)="onLoaded($event)"
(lazysnapError)="onError($event)"
[alt]="product.name"
width="800"
height="600"
/>Wire up once at app startup. Every image in your app reports automatically.
import { configure, createAnalyticsPlugin, createSegmentHandler } from "@lazysnap/core";
configure({
...createAnalyticsPlugin({
handler: createSegmentHandler(analytics),
sampleRate: 0.1, // track 10% — keeps volume manageable
events: ["image_loaded", "image_error"],
batch: { maxSize: 20, maxWaitMs: 2000 },
enrichEvent: (e) => ({
...e,
page: window.location.pathname,
experiment: getActiveExperiment(),
}),
}),
});Every image_loaded event gives you:
| field | value | description |
|---|---|---|
event |
"image_loaded" |
lifecycle event name |
src |
"/products/jacket.jpg" |
image URL |
attempts |
1 |
>1 means a retry succeeded |
timing.timeToLoad |
380 |
ms from viewport entry → loaded |
timing.timeToVisible |
2140 |
ms from page load → viewport entry |
connection.effectiveType |
"3g" |
Network Information API |
connection.downlink |
1.4 |
Mbps |
connection.rtt |
280 |
ms |
dimensions |
{ width: 1200, height: 800 } |
natural image size |
timestamp |
"2025-06-14T09:41:22Z" |
ISO 8601 |
image_error events include all the same fields plus errorMessage and the total attempt count.
- Which product images have an error rate above 1%?
- What is our p95 load time on 3G connections?
- What percentage of users scroll far enough to see image #5 in a product grid?
- Did our CDN change last week actually improve load times?
import { configure, createAnalyticsPlugin, consoleHandler } from "@lazysnap/core";
if (process.env.NODE_ENV !== "production") {
configure({ ...createAnalyticsPlugin({ handler: consoleHandler }) });
}Prints grouped, collapsible entries in the browser console:
▶ [lazysnap] image_loaded hero.jpg — 312ms on 4g (1200×800)
▶ [lazysnap] image_error product-02.jpg — Failed after 3 attempts on 3g
▶ [lazysnap] image_visible product-03.jpg — entered viewport (scroll depth: 1840ms)
Every loaded image also emits performance.mark() and performance.measure() entries automatically. They appear as lazysnap:load-duration:hero.jpg spans in the DevTools Performance panel.
| Handler | Use case |
|---|---|
createSegmentHandler(segment) |
Segment analytics.js / analytics-next |
createAmplitudeHandler(amplitude) |
Amplitude browser SDK |
createBeaconHandler(endpoint) |
Custom HTTP endpoint via sendBeacon |
consoleHandler |
Development and debugging |
noopHandler |
Tests / feature-flagged disabled state |
The browser makes one attempt per image and stops. LazySnap retries automatically. The LQIP placeholder stays visible throughout — the user never sees a broken state while retries are in progress.
observe(img, {
src: "/products/jacket.jpg",
retries: 3, // 3 retries after the first failure
retryDelay: 1500, // 1.5s between each attempt
onError: (entry, err) => {
// fires only after ALL retries are exhausted
// entry.attempts === 4 (1 original + 3 retries)
Sentry.captureException(err, {
extra: {
src: entry.options.src,
attempts: entry.attempts,
connection: entry.connection.effectiveType,
},
});
},
});On slow-2G with 25% packet loss, 2 retries reduce per-image failure rates from ~25% to ~1.6% — roughly 12 broken images per 48-image product grid down to fewer than one.
| feature | loading="lazy" |
lazysnap |
|---|---|---|
| Defer off-screen images | ✅ | ✅ |
| SSR safe | ✅ | ✅ |
| Zero JS required | ✅ | — |
| LQIP / blur-up placeholder | — | ✅ |
| Retry failed loads | — | ✅ |
| Load timing per image | — | ✅ |
| Viewport reach tracking | — | ✅ |
| Error callbacks | — | ✅ |
| Connection metadata (3G/4G) | — | ✅ |
| Analytics integration | — | ✅ |
| DevTools performance marks | — | ✅ |
All options can be set globally via configure() and overridden per image.
configure({
rootMargin: "0px 0px 200px 0px", // start loading 200px before viewport
transitionDuration: 400, // blur-up fade in ms (0 = disable)
placeholderColor: "#f0f0f0", // background while loading
retries: 2,
retryDelay: 1000,
});| option | type | default | description |
|---|---|---|---|
src |
string |
required | Full-resolution image URL |
placeholder |
string |
"" |
LQIP src or base64 data URI |
srcset |
string |
"" |
Responsive srcset string |
sizes |
string |
"" |
Responsive sizes string |
placeholderColor |
string |
"transparent" |
Background color while loading |
transitionDuration |
number |
400 |
Blur-up fade duration in ms |
rootMargin |
string |
"0px 0px 200px 0px" |
IntersectionObserver rootMargin |
threshold |
number | number[] |
0 |
IntersectionObserver threshold |
retries |
number |
2 |
Retry attempts on load failure |
retryDelay |
number |
1000 |
Delay between retries (ms) |
eager |
boolean |
false |
Load immediately — for LCP images |
onLoad |
(entry) => void |
— | Fires when fully loaded |
onError |
(entry, error) => void |
— | Fires after all retries fail |
onVisible |
(entry) => void |
— | Fires on viewport intersection |
For hero images that should load immediately without waiting for intersection:
// React
<LazyImage src="/hero.jpg" eager fetchpriority="high" decoding="async" alt="Hero" />
// Angular
<img lazysnap [lazysnapSrc]="heroUrl" [lazysnapEager]="true" alt="Hero" />
// Vanilla
observe(img, { src: "/hero.jpg", eager: true });Works with Next.js, Angular Universal, Remix, and any server renderer. The isBrowser() guard in core prevents any window or document access during server rendering. No configuration needed.
| feature | support |
|---|---|
| IntersectionObserver | Chrome 51+, Firefox 55+, Safari 12.1+, Edge 15+ |
| Network Information API | Chrome / Edge only — undefined elsewhere, no errors |
| Fallback (no IO support) | All browsers — loads eagerly |
| SSR | Full support — no-op on server |
- Node.js 18+
- pnpm 8+
git clone https://github.com/AlthafPattan/lazysnap.git
cd lazysnap
# Install all workspace packages
pnpm install
# Build core first (react/angular depend on it)
pnpm --filter @lazysnap/core build
# Then build the framework wrappers
pnpm --filter @lazysnap/react build
pnpm --filter @lazysnap/angular buildThis is a pnpm workspace (pnpm-workspace.yaml) covering packages/*. The apps/demo directory is a standalone HTML file and is not part of the workspace.
pnpm --filter @lazysnap/core build # rebuild core
pnpm --filter @lazysnap/react build # rebuild react wrapper
pnpm --filter @lazysnap/angular build # rebuild angular wrapper
pnpm --filter @lazysnap/core test # run unit tests
pnpm --filter @lazysnap/core test:coverage # with coverage report
# Open the demo — no server needed, open directly in a browser
open apps/demo/index.htmllazysnap/
├── packages/
│ ├── core/src/
│ │ ├── observe.ts main entrypoint
│ │ ├── observer.ts pooled IntersectionObserver
│ │ ├── loader.ts LQIP, blur-up, retry
│ │ ├── defaults.ts configure() + resolveOptions()
│ │ └── observability/
│ │ ├── analytics.ts plugin system + sampling + batching
│ │ ├── timing.ts performance.now() + marks
│ │ ├── connection.ts Network Information API
│ │ └── handlers.ts Segment, Amplitude, Beacon, console
│ ├── react/src/
│ │ ├── LazyImage.tsx forwardRef component
│ │ └── useLazyImage.ts hook
│ └── angular/src/
│ ├── lazysnap.directive.ts img[lazysnap] directive
│ └── lazysnap.module.ts NgModule
├── apps/demo/ standalone HTML demo (no server needed)
├── .github/workflows/ CI + publish pipelines
├── setup.sh first-time install script
└── tsconfig.base.json
Contributions are welcome.
- Fork the repo and create a branch:
git checkout -b feat/your-feature - Run
pnpm installthen build core before the wrappers (see Setup above) - Make your changes and add tests where relevant
- Ensure tests pass:
pnpm --filter @lazysnap/core test - Open a PR against
main
For bugs, open an issue with a minimal reproduction. For features, open an issue first to discuss before building.
Releases are automated. To publish a new version:
git tag v0.2.0
git push origin v0.2.0The publish workflow builds all three packages and publishes to npm with provenance.
MIT — see LICENSE
Made by Althaf Khan Pattan
If this is useful, consider giving it a star ⭐