Tiny (~5 KB) self-hosted popunder ad loader for content sites (manga/anime readers, streaming, forums). One static file, zero dependencies: round-robin across your ad networks, fire-on-click, idle-time injection (no LCP hit), optional per-network daily/hourly cap-skip, and an undetectable per-network audit counter.
Built by reverse-engineering how popunder networks (BetterJsPop/HilltopAds, AdMaven) actually behave, then tuning against real reader behavior. Not a network — a loader you point at your own ad networks.
Enter page → click → pop fires. Next page → click → pop fires. Each page-load serves the next network in your list (round-robin), re-injected fresh. No artificial session cap — pacing is left to each network's own dashboard (where it belongs).
- Multi-page reality: on a normal content site every navigation is a full page-load, so any injected tag is gone on the next page. edgekit re-injects the next network on every page-load — persisting only a tiny rotation cursor and click counter in
localStorage. - Round-robin, pick-next-available: each page-load advances to the next network, but skips any network that hit its declared
cap(see below) — so a maxed-out network never wastes a page-load loading a script that won't pop; the turn goes to a network that can still fill. Want a higher-eCPM network more often? List it more than once innetworks. - Performance-first injection: ad scripts are heavy and third-party. edgekit injects the tag at
requestIdleCallback(or the user's first interaction, whichever comes first, 1.5 s cap) — never during the critical render path, so it doesn't hurt LCP/FID.preconnectwarms the network domain up front so the idle-time fetch is fast. - Optional per-network cap (opt-in): declare
cap: { hour, day }matching a network's own dashboard setting and edgekit will skip loading it once the client-side audit hits that count. This is a skip-load optimization — it never blockswindow.open(that would hijack the tab). Leavecapoff and the network just self-throttles server-side as before. - Never blocks a tag's
window.open— the one hard rule (see below). Blocking it hijacks the user's tab. - Self-hosted, first-party: one static file on your own domain is harder for filter lists to block than a known ad-network URL, and config changes need no site redeploy.
<script>
window.EdgeKitConfig = {
networks: [
{ tag: '//your-network-tag/....js' }, // JS-tag popunder (inject each turn, native)
{ tag: 'https://x.com/tag.min.js', data: { zone: '11383304' } }, // zone-tag (Monetag-style): data-* attrs
{ key: 'popads', cap: { day: 2 }, html: '<script>…any snippet verbatim…</script>' }, // universal inline
{ url: 'https://your-smartlink/xxx', mode: 'under', cap: { hour: 3 } }, // direct-link (self-fired)
],
unlockClicks: 2, // for self-fired `url` networks: clicks needed to fire one pop
};
</script>
<script async src="https://edgekit-sdk.pages.dev/edgekit.js"></script>Or via data-*:
<script async src="https://edgekit-sdk.pages.dev/edgekit.js" data-unlock-clicks="2"></script>mode (self-fired url only): 'under' (default — tab-under: content opens in a new tab, the ad replaces the current one) or 'popup' (ad opens in a new foreground tab, current tab untouched).
data (JS-tag only): a map applied as data-* attributes on the injected <script> (set before src, so the tag reads them on load). For zone-based loaders like Monetag — { tag: 'https://x.com/tag.min.js', data: { zone: '11383304' } } injects <script src="…" data-zone="11383304">.
html (universal): paste the exact snippet a network gives you — inline <script> bootstraps (PopAds-style), banners, anything. edgekit parses it and re-creates the <script> elements so they execute (setting innerHTML alone won't run scripts). Use this for any format that isn't a plain src tag. Add key: 'somelabel' so it has a stable name in the audit (there's no URL to derive one from).
cap (any network): { hour?, day? } — skip loading this network once the client-side audit reaches that many pops in the rolling hour/day. Set it to match the network's own dashboard frequency cap. Omit for no skip.
Self-host
edgekit.min.jsfor production. Serve with a shortCache-Controlso config edits apply without touching your site.
State persisted across navigation (localStorage):
_ek6 = rotation cursor _ek4:<net> = per-link click counter _eks = per-network audit {h,d}
Each page-load:
pick = first network from _ek6 onward whose cap isn't reached (skip capped ones); _ek6 = pick+1
(all capped → this page loads no ad)
tag/html → wrap window.open (sync, to count) → inject at IDLE / first interaction (perf, no LCP hit).
{tag}=src(+data-*), {html}=verbatim snippet. Engine pops on click, self-throttles.
The window.open wrapper only COUNTS fires (audit) — it never blocks.
url → on a real gesture (isTrusted): first pop of the hour fires immediately (peak eCPM),
then every `unlockClicks` clicks (per-link counter):
window.open(clicked-href) → new tab (content, foreground)
location.replace(adUrl) → old tab (ad) ← tab-under
Inspect the per-network audit any time:
JSON.parse(localStorage._eks)
// { c: { "network-a.com": {h:1,hT:…,d:5,dT:…}, "popads": {h:0,…,d:2,…} } }h / d = real pops that network fired in the rolling hour / day — this is what cap checks against, and it lets you spot a network firing far more or less than its dashboard says.
WebKit allows only one window.open per click, so every popunder engine has a fallback: if
window.open is denied, it redirects the current tab to the ad (if (win) {...} else { location.href = url }).
If edgekit returned null to a running tag to enforce some limit, the engine would read that as
"blocked" (indistinguishable from an adblocker) and fire that fallback — hijacking the current
tab and destroying the user's navigation. So the wrapper only ever counts; it never blocks, and
it never delays injecting a tag either. Any limiting you want on a tag belongs in that network's
own dashboard. (Self-fired url networks are different: we call window.open ourselves, so
gating them with unlockClicks is completely safe — there's no engine to trigger a fallback on.)
| key | default | meaning |
|---|---|---|
networks |
[] |
ordered rotation list. Each entry is one of: {tag:'//src', data?} (JS tag), {html:'…', key?} (verbatim snippet), or {url:'https://…', mode?} (self-fired link). Any entry may add cap:{hour?,day?}. Repeat an entry to give it a bigger share. |
unlockClicks |
2 |
clicks needed to fire one self-fired-url pop (tag networks are ungated — their engine decides) |
- Mobile background tabs run load-time JS then freeze — the ad's impression pixel fires on load, so it counts. Verified against a live network.
- iOS PWA (standalone) / iframe: can't safely swap the top window → self-fired ad opens in a separate tab regardless of
mode. - Adblock blocks known network domains (EasyList) regardless of loader — self-hosting + shortlink
urls recover some. Nothing recovers a blocked domain fully. - JS-tag format matters: a tag set to a redirect format (not popunder) navigates the current tab. Use popunder/tab-under zones for reading sites.
- SEO: self-fire triggers only on real gestures (
isTrusted) → bots see clean content. Never touches the back button (avoids Google's 2026 back-button-hijack spam policy). - Tag-fire attribution is by "which network is injected on this page", not by inspecting the call stack — stacks are often redacted/empty for cross-origin scripts (notably iOS Safari).
- Popunder is inherently gray. Measure
returning-user ratealongside RPM. Don't run it where you can't afford the retention hit.
MIT. No warranty.