Auto-canceling, leak-free event pipelines in 320 bytes JS. Zero dependencies.
JavaScript
1
0 commits
updated Sep 19, 2026
zep:
Events are great, until they are not. Like when you need to debounce inputs, handle scroll spam, or fetch async data out-of-order.
zep was built for exactly those moments.
No more loose let variables, no more memory leaks, no event handling boilerplate. Just clean, composable event handling logic.
Zep is a bit of a slacker 🦥 as well- it does zero work and attaches no DOM listeners until you actually call
.on().
Without AbortSignal: use the returned cleanup function.
import { zep } from "@marsbos/zep";
import { map, filter, debounce, latest } from "@marsbos/zep/helpers";
const cleanup = zep(queryEl, "input")
.use(
map((e) => e.target.value.trim()), // <= transform input to string value
filter((query) => query.length > 2), // <= only pass if min length = 3
debounce(300), // <= wait 300 ms
latest(
(query, signal) =>
fetch(`https://dummyjson.com/products/search?q=${query}`, {
signal,
}).then((res) => res.json()), // <= auto abort previous or pending requests
),
)
.on((results) => {
console.log(results);
});
// Whenever you're ready, just call 'cleanup()'
With AbortSignal passed to zep.
zep(queryEl, "input", { signal }) // <= Pass a signal to zep
.use(
map((e) => e.target.value.trim()),
filter((query) => query.length > 2),
debounce(300),
latest((query, signal) =>
fetch(`https://dummyjson.com/products/search?q=${query}`, {
signal,
}).then((res) => res.json()),
),
)
.on((results) => {
console.log(results);
});
// controller.abort() will do the cleanup via the signal passed to zep.
Everything in Zep execution pipelines is 100% synchronous. When an event fires, it passes through filter, map, take, and your .on() listener instantly within the very same callstack. No microtask queues, no hidden schedulers.
The only exception is latest() (and timing operators like debounce / throttle / raf which delay execution):
latest() introduces an asynchronous boundary because it handles Promises and async operations. It passes an AbortSignal to your async callback and automatically cancels pending executions when a new event arrives.ms have passed.condition.automatically aborts previous/pending async tasks on new events.requestAnimationFrame for smooth UI updates.ms.export const log = (eventName) => (onDestroy, stop) => (next) => {
let count = 0;
return (value) => {
console.log(new Date().toISOString(), `${++count} events for ${eventName}`);
next(value);
};
};
// Usage: zep(myButton, 'click').use(map(...), log("myEvent")).on(...);
Keep UI animations butter smooth by decoupling scroll events from rendering.
import { zep } from "@marsbos/zep";
import { raf } from "@marsbos/zep/helpers";
zep(window, "scroll")
.use(raf())
.on(() => {
const scrolled = window.scrollY;
// Perform smooth layout/UI updates here
});
Unsubscribe after 1 successfull event
import { zep } from "@marsbos/zep";
import { take, filter } from "@marsbos/zep/helpers";
zep(document, "keydown")
.use(
filter((e) => e.key === "Escape"), // <= We only want to act on the Escape key
take(1), // <= Stop and unsubscribe
)
.on(() => closeModal());
Avoid layout thrashing when measuring window bounds.
import { zep } from "@marsbos/zep";
import { throttle, map } from "@marsbos/zep/helpers";
zep(window, "resize")
.use(
throttle(200),
map(() => ({ width: window.innerWidth, height: window.innerHeight })),
)
.on(({ width, height }) => {
console.log(`Window resized to: ${width}x${height}`);
});
zep.js is extremely lightweight and distributed as an ES module with zero dependencies:
Core: ~417 bytes minified (265 bytes gzipped)Helpers: ~922 bytes minified (484 bytes gzipped)import { zep } from "@marsbos/zep";
import { filter, etc... } from "@marsbos/zep/helpers";
Marcel Bos
MIT
I hate testing and TypeScript. So yeah, I am a bit of a slacker 🦥.
JavaScript
92.0%
Shell
8.0%
Auto-canceling, leak-free event pipelines in 320 bytes JS. Zero dependencies.
JavaScript
1
0 commits
updated Sep 19, 2026
zep:
Events are great, until they are not. Like when you need to debounce inputs, handle scroll spam, or fetch async data out-of-order.
zep was built for exactly those moments.
No more loose let variables, no more memory leaks, no event handling boilerplate. Just clean, composable event handling logic.
Zep is a bit of a slacker 🦥 as well- it does zero work and attaches no DOM listeners until you actually call
.on().
Without AbortSignal: use the returned cleanup function.
import { zep } from "@marsbos/zep";
import { map, filter, debounce, latest } from "@marsbos/zep/helpers";
const cleanup = zep(queryEl, "input")
.use(
map((e) => e.target.value.trim()), // <= transform input to string value
filter((query) => query.length > 2), // <= only pass if min length = 3
debounce(300), // <= wait 300 ms
latest(
(query, signal) =>
fetch(`https://dummyjson.com/products/search?q=${query}`, {
signal,
}).then((res) => res.json()), // <= auto abort previous or pending requests
),
)
.on((results) => {
console.log(results);
});
// Whenever you're ready, just call 'cleanup()'
With AbortSignal passed to zep.
zep(queryEl, "input", { signal }) // <= Pass a signal to zep
.use(
map((e) => e.target.value.trim()),
filter((query) => query.length > 2),
debounce(300),
latest((query, signal) =>
fetch(`https://dummyjson.com/products/search?q=${query}`, {
signal,
}).then((res) => res.json()),
),
)
.on((results) => {
console.log(results);
});
// controller.abort() will do the cleanup via the signal passed to zep.
Everything in Zep execution pipelines is 100% synchronous. When an event fires, it passes through filter, map, take, and your .on() listener instantly within the very same callstack. No microtask queues, no hidden schedulers.
The only exception is latest() (and timing operators like debounce / throttle / raf which delay execution):
latest() introduces an asynchronous boundary because it handles Promises and async operations. It passes an AbortSignal to your async callback and automatically cancels pending executions when a new event arrives.ms have passed.condition.automatically aborts previous/pending async tasks on new events.requestAnimationFrame for smooth UI updates.ms.export const log = (eventName) => (onDestroy, stop) => (next) => {
let count = 0;
return (value) => {
console.log(new Date().toISOString(), `${++count} events for ${eventName}`);
next(value);
};
};
// Usage: zep(myButton, 'click').use(map(...), log("myEvent")).on(...);
Keep UI animations butter smooth by decoupling scroll events from rendering.
import { zep } from "@marsbos/zep";
import { raf } from "@marsbos/zep/helpers";
zep(window, "scroll")
.use(raf())
.on(() => {
const scrolled = window.scrollY;
// Perform smooth layout/UI updates here
});
Unsubscribe after 1 successfull event
import { zep } from "@marsbos/zep";
import { take, filter } from "@marsbos/zep/helpers";
zep(document, "keydown")
.use(
filter((e) => e.key === "Escape"), // <= We only want to act on the Escape key
take(1), // <= Stop and unsubscribe
)
.on(() => closeModal());
Avoid layout thrashing when measuring window bounds.
import { zep } from "@marsbos/zep";
import { throttle, map } from "@marsbos/zep/helpers";
zep(window, "resize")
.use(
throttle(200),
map(() => ({ width: window.innerWidth, height: window.innerHeight })),
)
.on(({ width, height }) => {
console.log(`Window resized to: ${width}x${height}`);
});
zep.js is extremely lightweight and distributed as an ES module with zero dependencies:
Core: ~417 bytes minified (265 bytes gzipped)Helpers: ~922 bytes minified (484 bytes gzipped)import { zep } from "@marsbos/zep";
import { filter, etc... } from "@marsbos/zep/helpers";
Marcel Bos
MIT
I hate testing and TypeScript. So yeah, I am a bit of a slacker 🦥.
JavaScript
92.0%
Shell
8.0%