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)

Same widget, all new insides

I’ve written about this widget before. It still does one job: show whatever 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 cannot see.

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

Live one is at fast.jamlog.lol.

I rebuilt it because the old version had a scaling problem I could not tune my way out of. This post covers what got better and what costs more to run now. If you want the story of the original build, that one’s written up separately.

What the first version got right

The Next.js build wasn’t a mistake. It shipped, people used it, it proved the idea worked.

The idea was to put the whole widget config in the URL hash, decode it on a /w page, and render it. No accounts, no database row per user, the link is the save file. I kept that part untouched. It is still the best decision in the project.

It handled the boring parts well too. Adaptive polling so the overlay didn’t hammer Last.fm, local progress estimation so the bar didn’t flicker when the API lagged, a session-key path for private profiles. None of that got thrown away. It got rewritten and cleaned up.

So the first version did its job. It found the product, and then the product ran into problems the old setup could not fix.

Where it started hurting

Once streamers with actual audiences started using it.

One IP against a rate limit

This is the one that forced the rewrite. Every viewer’s widget fetched Last.fm through my server, so when a streamer went live, dozens of browsers were all asking one server for their now-playing data, and that server hit Last.fm from a single IP.

Last.fm caps you at roughly 5 requests per second per IP. A handful of viewers is fine. Fifty tabs sharing one budget means the overlay starts getting throttled at the moment people are watching.

No setting fixed that. The architecture funneled all the traffic into the one place that could not scale.

The grid editor

The original editor placed elements on a fixed grid. Album art here, title there, slots you filled in. It worked, and every layout came out looking like a variation of the same template. Want the artist name floating in the bottom corner with a custom offset? The grid had no answer for that.

People wanted to design their overlay and the grid could not do it.

A framework doing too much

Next.js is a lot of machinery for what this app is. A static editor page, a static widget page, both running 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

The new build is a different runtime model and a different repo layout, not Next.js with tidier files.

The monorepo

Two apps under one Bun workspace.

apps/web is a SvelteKit single-page app built with adapter-static, so it’s pure client-side rendering with 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. Svelte 5 with runes, 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.

Two apps is more than one, sure. Each piece has one job now instead of one framework doing all of them at once.

Calling Last.fm from the browser

Biggest change in the rewrite, so it gets its own section.

Last.fm and its album-art CDN both send Access-Control-Allow-Origin: *, which means 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 instead of one server choking on all of it.

Last.fm requests per minute on one IP as viewers scale
Loading chart…

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

The server is still there as the fallback. If a direct call fails at the transport level, a network blip or a CORS hiccup, the client retries through /api/lastfm/*.

Private profiles used to be the exception, since a hidden listening profile needs a signed request and the signature needs the Last.fm shared secret, which never leaves the server. What killed the exception is that Last.fm signatures carry no timestamp or nonce, so a signed URL stays valid as long as the session key does. The server signs the recent-tracks URL exactly once, hands it to the browser, and the browser polls Last.fm directly with it, same as a public profile. One signing request, then every poll after that is on the viewer’s own IP.

There is a BYOK option too. Drop in your own Last.fm API key and your widget uses it for the direct calls, so a throttle or revocation on the shared key never reaches you. The key rides along in the config like everything else so it survives the trip into OBS.

Polling

Last.fm pushes nothing. There is no websocket telling you a song changed, so the widget keeps asking, and the only question is how often.

Now that every widget polls from its viewer’s own IP there’s no shared budget to protect, and Last.fm’s ~5 req/sec per-IP allowance makes once a second comfortable. So that’s what it does:

Seconds between polls by widget state
Loading chart…

Playing or not, it polls every second, so track changes, pauses, skips, and playback starting all show up within about a second. The only backoff left is a hidden tab, like an editor sitting open in the background, which drops to 5 seconds and stops wasting requests.

OBS browser sources report as visible, so overlays never hit that backoff and hold the one-second pace.

Between polls the progress bar doesn’t freeze waiting on 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 and $derived for progress and percent, so the UI reacts on its own.

The annoying edge cases

This is where most of the work went. Last.fm tells you a track is “now playing” but never tells you where in the track you are, and that gap creates three annoying problems.

Pause detection. A lot of scrobblers keep a song flagged “now playing” right through a pause. The only signal you get is your locally-estimated progress running past the track’s own length. Once it overruns the duration plus an eight-second grace period (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 checks recent scrobbles to estimate where playback is, so the bar lands close to the right spot instead of snapping to the start.

Loops and replays. Put a song on repeat and a naive widget sits there thinking it has been paused for ten minutes. The code watches scrobble timestamps, and if the same track scrobbles again more than a full duration after it started, it looped, so the widget re-anchors instead of freezing.

The editor: grid out, free layout in

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

You also get per-element fonts, colors, and shadows, plus a switch animation for track changes. It is a layout tool now instead of a fill-in-the-blanks form.

The part I am happiest about is that this shipped without breaking one existing design. A version flag rides along in the encoded config: missing or 1 means the old grid, 2 means free layout. That flag picks the renderer, either 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 rebuilds the same look as a free layout, so everything is movable from there. It also carries the legacy fields through untouched, so if the version flag ever got lost the design falls back to the grid instead of breaking.

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, 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.

Locking down the server

It is small, and it is the part facing the open internet.

The image proxy, which is the fallback path for album art, only allows known CDN hosts. An open image proxy is an SSRF hole, and the allowlist closes it. There is also a loose per-IP rate limit, 60 requests per 10 seconds, that normal polling never touches. It only trips on someone spamming the endpoint.

My favorite property of the new server is that it fails open. Redis and Postgres are both optional, and if either falls over the widget keeps serving. You lose caching or visitor logging. The overlay stays up.

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 for one second, 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, so there’s no manual migrate step on deploy.

Why the new one is better

The scaling problem is gone, and no part of that came from picking a trendier framework. Moving Last.fm calls into each viewer’s browser turned one shared bottleneck into a hundred independent budgets, which is the difference between an overlay that dies under an audience and one that holds at 200 plus concurrent viewers.

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

It wasn’t free though

It was not all upside.

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

Browser-direct calls also mean the public API key ships in the client bundle. It is a public key, and BYOK exists for anyone who wants their own, but it is sitting out there in plain sight. That is the trade for killing the single-IP bottleneck.

The no-server-save model cuts both ways. Lose the URL, lose the design. The localStorage autosave catches most cases, and the 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, even for private profiles; 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
When something breaksServer in the hot pathFail-open Redis + Postgres

The first version was the right way to find out if this was worth building. This one is the right way to run it.

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