skip to content

The New Last.fm Now Playing Overlay

How I rebuilt a Next.js music overlay into a Bun + Hono + SvelteKit monorepo, moved Last.fm calls into the browser to scale past 200 viewers, and swapped a fixed grid editor for free positioning.

15 min read
Try it live (opens in a new tab)

Overview

Same product, very different machine underneath. The widget still does one job: show what you’re playing on Last.fm as an OBS browser source, with the whole design baked into a shareable URL. What changed is everything you don’t see.

The first version was a single Next.js app on Vercel. The version running today is a Bun + Hono + SvelteKit monorepo on Railway, and it calls Last.fm from each viewer’s own browser instead of routing every request through one server.

You can try the live one at fast.jamlog.lol.

The tool swap isn’t the interesting part. The interesting part is why I rebuilt it, what got better, and what now costs more to run. If you want the story of the original build, I wrote that one up separately — this post is about the rewrite.

What The First Version Got Right

The Next.js build was not a mistake. It shipped, people used it, and it proved the core idea.

That core idea held up perfectly: put the entire widget config in the URL hash, decode it on a /w page, and render. No accounts. No database row per user. A link is the save file. I kept that whole concept word for word, because it was the best decision in the project.

The original also nailed the boring-but-critical stuff. Adaptive polling so the overlay didn’t hammer Last.fm. Local progress estimation so the bar didn’t flicker every time the API lagged. A session-key path for private profiles. None of that got thrown away — it got rewritten and sharpened.

So the first version did its job. It found the product. Then the product outgrew the shape it was built in.

Where The Next.js Build Started To Hurt

The cracks showed up once real streamers started using it with real audiences.

One IP Against A Rate Limit

This was the big one. Every viewer’s widget fetched Last.fm through the server. One streamer going live with an audience meant dozens of browsers all asking my single server to fetch their now-playing data, and that server hit Last.fm from one IP.

Last.fm caps you at roughly 5 requests per second per IP. Do the math. A handful of viewers is fine. A popular streamer with fifty tabs open shares one budget, and the whole overlay starts getting throttled the moment it matters most — when people are actually watching.

That’s not a tuning problem. It’s a shape problem. The architecture funneled traffic into the exact place that couldn’t scale.

A Fixed Grid Editor

The original editor placed elements on a fixed grid. Album art here, title there, slots you filled in. It worked, but every layout looked like a variation on the same template. Want the artist name floating in the bottom corner with a custom offset? Tough.

People wanted to design, not fill in blanks. The grid couldn’t give them that.

A Framework Doing Too Much

Next.js is great. It’s also a lot of machine for what this app actually is: a static editor page and a static widget page that run entirely in the browser, plus a thin API. I was paying for SSR I never used and a build pipeline heavier than the job needed.

What Changed In The Refactor

The new build isn’t Next.js with cleaner files. It’s a different runtime model and a different repo layout.

The Monorepo

The project splits into two apps under one Bun workspace.

apps/web is a SvelteKit single-page app built with adapter-static — pure client-side rendering, no SSR. It owns the drag-and-drop editor, encoding and decoding the config to and from the URL, polling Last.fm, and rendering the widget. It’s Svelte 5 with runes and Tailwind v4.

apps/server is a Bun-powered Hono service. In production it serves both the static build and the API on a single port. Redis sits in front of the few signed and proxied Last.fm paths, and Postgres (through Drizzle) backs optional analytics and contact emails.

That split sounds like more, and it is. But each piece now has one clear job instead of one framework pretending to be all of them.

Browser-Direct Last.fm

This is the headline change, so it gets its own section.

Last.fm and its album-art CDN both send Access-Control-Allow-Origin: *. That one detail unlocks everything: each viewer’s browser can call ws.audioscrobbler.com directly. Public lookups — recent tracks, track info, album art, color extraction — now fire straight from the viewer’s machine, on the viewer’s own IP.

So every viewer spends their own per-IP budget. A streamer with a hundred viewers is a hundred separate IPs hitting Last.fm, not one server choking on the aggregate.

Last.fm requests funneled through a single IP, per minute (illustrative)
Loading chart…

The old line climbs with every viewer. The new line just sits there, flat, because the load spreads across as many IPs as there are viewers. Hit the Δ Compare toggle on the chart to see the gap fill in.

The server didn’t disappear, though. It’s the fallback. If a direct call dies at the transport level — a network blip, a CORS hiccup — the client quietly retries through /api/lastfm/*. And private profiles always go through the server, because a hidden listening profile needs a signed request, and the signature needs the Last.fm shared secret.

There’s also a BYOK option. Drop in your own Last.fm API key and your widget uses it for the direct calls — handy if you’re a heavy user who wants near-instant updates on a budget nobody else shares. The key rides along in the config like everything else, so it survives the trip into OBS.

Smarter Polling

Last.fm doesn’t push. There’s no websocket telling you a song changed, so the widget has to ask. The trick is asking at the right speed.

Now that load is spread across viewers, the poll can be brisk without fear. The cadence adapts to what’s happening on screen:

Seconds between polls by widget state
Loading chart…

Right after a track changes, it polls every 2.5 seconds so a pause or skip gets caught fast. While something’s playing, it settles to roughly every 4 seconds. Nothing playing? Back off to 10. Tab hidden — an inactive editor, say — drop all the way to 20 and stop wasting requests.

OBS browser sources report as visible, so overlays keep the snappy cadence even when you’re not looking at them. That’s the behavior you want.

Between polls, the progress bar doesn’t sit frozen waiting for the next fetch. It ticks locally off the track’s reported duration, driven by requestAnimationFrame, so it animates smoothly. The whole thing is a Svelte 5 runes class — $state for the live fields, $derived for progress and percent — so the UI just reacts.

The Annoying Edge Cases

Here’s where the rewrite earned its keep. Last.fm tells you a track is “now playing” but never tells you where in the track you are. That gap creates three nasty problems, and the new code handles all of them.

Pause detection. A lot of scrobblers keep a song flagged “now playing” right through a pause. The only signal you get is that your locally-estimated progress runs past the track’s own length. Once it overruns the duration plus an eight-second grace — enough to ride out the gap between songs without falsely flashing “paused” — the widget marks it paused.

Resume estimation. Start OBS halfway through a song and a naive widget shows the progress bar at zero. The new code peeks at recent scrobbles to estimate where playback actually is, so the bar lands in roughly the right place instead of snapping to the start.

Loops and replays. Put a song on repeat and a dumb widget gets stuck thinking it’s been “paused” for ten minutes. The code watches scrobble timestamps — if the same track scrobbles again more than a full duration after it started, it looped, so it re-anchors instead of freezing.

The Editor: Grid To Free Layout

The new editor throws out the fixed grid. Every element — background, art, title, artist, album, progress bar, duration, pause badge — now has free x/y/w/h, a z-index, and optional snap relationships to other elements. Drag anything anywhere. Snap an element’s edge to another’s and the relationship sticks, with the gap captured at drop time.

You also get per-element fonts, colors, and shadows, plus a switch animation for when the track changes. It’s a real layout tool now, not a slot-filler.

The clever bit is how this rolled out without breaking a single existing design. A version flag rides along in the encoded config: absent or 1 means the old grid; 2 means free layout. That flag picks the renderer — WidgetLegacy.svelte or WidgetV2.svelte.

export function isV2(c: WidgetConfig | null | undefined): c is WidgetConfig & { v2: WidgetV2 } {
return !!c && c.version === 2 && !!c.v2;
}

When an old grid design loads, a migrateToV2 step reads the legacy art position, text stack, and shadow settings and reconstructs the equivalent free layout — same look, now fully movable. It even carries the legacy fields through untouched, so if that version flag ever got lost, the design still degrades gracefully back to the grid.

The URL Is Still The Document

This part survived the rewrite on purpose. When you’re happy with a design, the whole config serializes to JSON, gets base64url-encoded, and goes into the widget URL’s hash: /w#<blob>. The widget page reads it back, polls Last.fm, and renders.

export function encodeConfig(c: WidgetConfig): string {
const json = JSON.stringify(c);
return btoa(unescape(encodeURIComponent(json)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

The URL is the save file. Copy it, paste it into OBS, done. No account to make, nothing stored on my server, nothing to leak. The editor keeps a localStorage autosave as a safety net, but the source of truth is the link in your clipboard.

Hardening The Server

The server is small, but it’s the part facing the open internet, so it’s locked down.

The image proxy — the fallback for album art — is host-allowlisted to known CDNs only. That’s deliberate: an open image proxy is an SSRF waiting to happen, and an allowlist shuts that door. There’s a lenient per-IP rate limit too (60 requests / 10s) that normal polling never touches; it only trips on abusive bursts.

The nicest property is that the whole thing fails open. Redis and Postgres are both optional. If either falls over, the widget keeps serving — you just lose caching or visitor logging, not the overlay.

What still works when a backing service is down (fail-open behavior)
Everything upYesYesYesYes
Redis downYesNoOff (fails open)Yes
Postgres downYesYesYesNo
Both downYesNoOffNo

Caching is short on purpose: recent-tracks responses live three seconds, track-info for a day. The whole stack ships as one Railway service plus the Redis and Postgres plugins, and Drizzle migrations apply on the server’s first write — no manual migrate step on deploy.

Why The New Version Is Better

It comes down to a few things, and none of them is “I used a trendier framework.”

The scaling problem is gone. Pushing Last.fm calls into each viewer’s browser turned a single shared bottleneck into a hundred independent budgets, which is the difference between an overlay that dies under an audience and one that’s happy with 200-plus concurrent viewers.

The editor is a real design tool now, and the migration means nobody’s old URL broke to get there. The runtime is lighter — one Bun process serving a static SPA and a thin API, no SSR tax on pages that were always client-only. And the server fails open, so an outage in Redis or Postgres degrades a feature instead of taking down the widget.

The Refactor Is Not Free

I’d be lying if I said it was all upside.

There are more moving parts now. The old build was basically one Next app. The new one is a monorepo with two apps, Redis, Postgres, and the wiring between them. That’s real operational weight, even with everything failing open.

Browser-direct calls mean the public API key ships in the client bundle. It’s a public key, and BYOK exists for anyone who wants their own, but it’s still out there in plain sight — that’s the trade for ditching the single-IP bottleneck.

And the no-server-save model cuts both ways. Lose the URL and you lose the design. The localStorage autosave catches most cases, but a link is still the only real backup.

Old Vs New

AreaOriginalRefactor
App shapeOne Next.js appBun monorepo: apps/web + apps/server
FrontendReact / Next.jsSvelteKit SPA (adapter-static, Svelte 5 runes)
BackendNext API routesBun + Hono service
HostingVercelRailway (one service + Redis + Postgres)
Last.fm callsAll through the server (one IP)Browser-direct, server as fallback
Scale ceilingThrottled past a handful of viewers200+ concurrent, each on its own IP
EditorFixed gridFree positioning with snapping (x/y/w/h, z, snaps)
Old designsn/amigrateToV2 keeps every old URL working
ResilienceServer in the hot pathFail-open Redis + Postgres

Closing

The first version was the right way to find the product. The refactor is the right way to run it.

I kept the one idea that was always good — the link is the whole document — and rebuilt the machine around it so it could actually scale, actually let people design, and actually survive a backing service falling over. Same overlay. Far more solid underneath.

Try it at fast.jamlog.lol, and the source is here: