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

This is the same widget I’ve written about before, and 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 basically 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 instead of routing every request through my server, it calls Last.fm from each viewer’s own browser.

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

I didn’t rewrite it just to swap frameworks. I rewrote it because the old version had a scaling problem I couldn’t tune my way out of, so this post covers 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.

What The First Version Got Right

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

The idea being: put the entire 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 concept exactly as it was, because it’s still the best decision in the whole project.

It also handled a lot of the boring stuff well. Adaptive polling so the overlay didn’t hammer Last.fm, local progress estimation so the bar didn’t flicker whenever the API lagged, and a session-key path for private profiles. None of that got thrown away, it just 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 couldn’t fix.

Where The Next.js Build Started To Hurt

The problems showed up once streamers with actual audiences started using it.

One IP Against A Rate Limit

This was the big one. 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, but fifty tabs sharing one budget means the overlay starts getting throttled right when people are actually watching.

There was no setting I could tweak to fix that. The architecture funneled all the traffic into the one 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 ended up looking like a variation of the same template. If you wanted the artist name floating in the bottom corner with a custom offset, you were out of luck.

People wanted to actually design their overlay, and the grid couldn’t do that.

A Framework Doing Too Much

Next.js is great, but it’s a lot of machinery 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 just Next.js with cleaner files, it’s a different runtime model and a different repo layout.

The Monorepo

The project is split into 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. 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.

Two apps sounds like more than one, and it is, but each piece has one clear job now instead of one framework trying to do all of them.

Browser-Direct Last.fm

This is the biggest change, 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 like recent tracks, track info, album art, and color extraction now fire straight from the viewer’s machine, on the viewer’s own IP.

That means 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 funneled through a single IP, per minute (illustrative)
Loading chart…

The old line climbs with every viewer. The new one just stays 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 go away though, it’s the fallback. If a direct call fails at the transport level, like a network blip or a CORS hiccup, the client quietly 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. The trick that removed the exception: Last.fm signatures carry no timestamp or nonce, so a signed URL stays valid for as long as the session key does. The server signs the recent-tracks URL exactly once, hands it to the browser, and the browser then 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’s also a BYOK option. Drop in your own Last.fm API key and your widget uses it for the direct calls, which insulates you from anything that ever happens to the shared key. The key rides along in the config like everything else, so it survives the trip into OBS.

Smarter Polling

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

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, the widget polls every second, so track changes, pauses, skips, and playback starting all show up within about a second of happening. The only backoff left is a hidden tab, like an editor left 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 keep the one-second pace, which is exactly what you want.

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 just reacts on its own.

The Annoying Edge Cases

This is where most of the actual 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 actually is, so the bar lands roughly in the right spot 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, 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 To Free Layout

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. You can 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 when the track changes. It’s an actual layout tool now instead of a fill-in-the-blanks form.

The part I’m most happy with is that this rolled out without breaking a single 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 that point on. It also carries the legacy fields through untouched, so if the version flag ever got lost, the design still 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, 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, and you’re done. There’s no account to make, nothing stored on my server, and 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 path for album art, only allows known CDN hosts. That’s on purpose, since an open image proxy is an SSRF waiting to happen, and an allowlist shuts that door. There’s also a lenient per-IP rate limit (60 requests / 10s) that normal polling never touches, it only trips on abusive bursts.

My favorite property of the new server is that the whole thing fails open. Redis and Postgres are both optional, and if either falls over, the widget keeps serving. You 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 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 Version Is Better

It’s not because I picked a trendier framework.

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

The editor is an actual design tool now, and thanks to the migration, nobody’s old URL broke to get there. The runtime is lighter too, 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 degrades a feature instead of taking the widget down.

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, and 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 also 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 sitting out there in plain sight. That’s the trade for getting rid of the single-IP bottleneck.

And the no-server-save model cuts both ways. If you lose the URL, you lose the design. The localStorage autosave catches most cases, but 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
ResilienceServer in the hot pathFail-open Redis + Postgres

Closing

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

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

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