lazysnap
v0.0.1
Published
Monorepo for @lazysnap/* — image loading with observability, retry, and blur-up
Downloads
118
Readme
lazysnap
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
The problem
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.
Packages
| 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 |
Install
# core — any framework
npm install @lazysnap/core
# react
npm install @lazysnap/react
# angular
npm install @lazysnap/angularQuick start
Vanilla JS
import { 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 changeReact
import { 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,
})}
/>
);
}Angular
// 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"
/>Observability
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.
Questions you can now answer
- 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?
Dev mode
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.
Available handlers
| 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 |
Retry on failure
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.
vs loading="lazy"
| 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 | — | ✅ |
Configuration
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,
});Full option reference
| 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 |
LCP images (above the fold)
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 });SSR
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.
Browser support
| 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 |
Development
Prerequisites
- Node.js 18+
- pnpm 8+
Setup
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.
Commands
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.htmlRepository structure
lazysnap/
├── 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.jsonContributing
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.
Releasing
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.
License
MIT — see LICENSE
Made by Althaf Khan Pattan
If this is useful, consider giving it a star ⭐
