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.
Try it live (opens in a new tab)- View more posts using Svelte 5
- View more posts using SvelteKit
- View more posts using Bun
- View more posts using Hono
- View more posts using TypeScript
- View more posts using Tailwind CSS
- View more posts using Redis
- View more posts using PostgreSQL
- View more posts using Drizzle
- View more posts using Railway
Same widget, completely different 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 basically everything you can’t 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 didn’t rewrite it 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 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 being: put the whole widget config in the URL hash, decode it on a /w page, 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 entire project.
It handled a lot of the boring stuff 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 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 it started hurting
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. Fifty tabs sharing one budget means the overlay starts getting throttled at exactly the moment 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.
The 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 came out looking like a variation of the same template. Want the artist name floating in the bottom corner with a custom offset? Too bad.
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 really 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
The new build isn’t Next.js with cleaner files. It’s a different runtime model and a different repo layout.
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. But each piece has one clear job now instead of one framework trying to do all of them at once.
Calling Last.fm from the browser
Biggest change in the whole 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.
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 didn’t go away, it’s the fallback. If a direct call fails at the transport level, 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 killed the exception: 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’s a BYOK option too. Drop in your own Last.fm API key and your widget uses it for the direct calls, so nothing that ever happens to the shared key touches you. The key rides along in the config like everything else so it survives the trip into OBS.
Polling
Last.fm doesn’t push anything. 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:
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 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 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 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’s an actual layout tool now instead of a fill-in-the-blanks form.
The part I’m happiest about 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 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’s small, but it’s the part facing the open internet.
The image proxy, which is 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 loose per-IP rate limit (60 requests / 10s) that normal polling never touches, it only trips when someone’s spamming it.
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.
| Everything up | Yes | Yes | Yes | Yes |
| Redis down | Yes | No | Off (fails open) | Yes |
| Postgres down | Yes | Yes | Yes | No |
| Both down | Yes | No | Off | No |
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
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, which is 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, 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
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 weight to run, 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 killing the single-IP bottleneck.
And the no-server-save model cuts both ways. Lose the URL, lose the design. The localStorage autosave catches most cases but the link is still the only real backup.
Old vs new
| Area | Original | Refactor |
|---|---|---|
| App shape | One Next.js app | Bun monorepo: apps/web + apps/server |
| Frontend | React / Next.js | SvelteKit SPA (adapter-static, Svelte 5 runes) |
| Backend | Next API routes | Bun + Hono service |
| Hosting | Vercel | Railway (one service + Redis + Postgres) |
| Last.fm calls | All through the server (one IP) | Browser-direct, even for private profiles; server as fallback |
| Scale ceiling | Throttled past a handful of viewers | 200+ concurrent, each on its own IP |
| Editor | Fixed grid | Free positioning with snapping (x/y/w/h, z, snaps) |
| Old designs | n/a | migrateToV2 keeps every old URL working |
| When something breaks | Server in the hot path | Fail-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 actually run it.
Try it at fast.jamlog.lol, source is here: