So, here’s the scenario: I wanted my GitHub profile (README.md) to feel alive. Not just those static "Stats" cards everyone has, but something truly real-time. Maybe a live Bitcoin ticker, a "currently playing" Spotify track, or just a clock that actually ticks.
I thought, "Easy. I'll just stream a GIF."
I spun up a Node.js server, set up a multipart stream, and... it worked! But there was one massive, annoying problem.
💀 The "Spinner of Death"
When you stream a GIF (or MJPEG) to a browser, the request never ends. The browser thinks the file is still downloading (because it is), so the tab’s favicon turns into a permanent loading spinner.
If you put this on your GitHub profile, the moment someone lands on your page, their browser looks like it's struggling. It feels janky. It feels broken.
I refused to accept that. I wanted the live updates, but I wanted the browser to think the request was finished.
💡 The "Refresh" Header Hack
I went down a rabbit hole of old-school web protocols and found a relic from the Netscape era: the Refresh header.
Instead of keeping the connection open forever (Streaming), we do this:
Server generates one single frame.
Server sends that frame with a specific header: Refresh: 1.
Server closes the connection immediately.
The Magic: The browser receives the image, stops the loading spinner (because the request is done!), and then—obediently—waits 1 second and requests the URL again.
Visually? It looks like a 1 FPS video. Technically? It’s a series of discrete, finished HTTP requests. Zero loading spinner.
🛠️ The Build (Node.js + Canvas)
I kept the stack simple: Express for the server, node-canvas to draw the pixels, and gifencoder.
Here is the core logic. Notice we aren't using setInterval anymore. We just draw once per request.
app.get('/live-status.gif', (req, res) => {
// Setup Canvas
const canvas = createCanvas(400, 150);
const ctx = canvas.getContext('2d');
// Draw your "Hacker" UI
ctx.fillStyle = '#0d1117'; // GitHub Dark Bg
ctx.fillRect(0, 0, 400, 150);
ctx.fillStyle = '#0f0'; // Matrix Green
ctx.font = '24px Monospace';
ctx.fillText(`BTC: $${getCurrentPrice()}`, 20, 50);
// The Magic Header
// "Refresh: 1" tells the browser to reload this URL in 1 second
res.set({
'Content-Type': 'image/gif',
'Cache-Control': 'no-cache',
'Refresh': '1'
});
// Send & Close
encoder.start();
encoder.addFrame(ctx);
encoder.finish();
res.send(encoder.out.getData());
});
⚡ Why This Approach is Stupidly Efficient
When I first built this, I worried about performance. Is making a new HTTP request every second bad?
Actually, for this use case, it's more scalable than streaming.
- No Open Connections: In a streaming architecture, if 5,000 people view your profile, your server holds 5,000 open socket connections. That eats memory fast. With the "Refresh" hack, the server sends the data and immediately forgets the user. It’s stateless.
- The "O(1)" Cache: I added a simple TTL Cache. If 1,000 requests hit the server in the same second, I only draw the canvas once. The other 999 users just get a copy of the memory buffer.
- Low Memory Footprint: Since we aren't managing unique user states or long-lived streams, this server can run comfortably on a tiny 512MB VPS or a free-tier container.
⚠️ The "It Works on My Machine" Trap
If you try to deploy this, you will hit a wall. I learned this the hard way.
node-canvas isn't just JavaScript; it binds to C++ libraries like Cairo and Pango. If you deploy this to a fresh Ubuntu VPS or a lightweight Docker container, it will crash and burn because those system libraries are missing.
I added a specific "System Requirements" section to my repo because of this. You need to install libcairo2-dev and friends before npm install will even work.
(Don't worry, I put the exact commands in the README so you don't have to StackOverflow it).
🚀 Optimization: Don't DDOS Yourself
The problem with the Refresh approach is that if 1,000 people view your GitHub profile, your server gets 1,000 requests per second. Rendering a Canvas 1,000 times a second will melt your CPU.
I added a simple TTL Cache. If a request comes in and the last image was generated <900ms ago, I just serve the cached buffer.
if (lastBuffer && (Date.now() - lastGenerated) < 900) {
return res.send(lastBuffer);
}
Now, my server only does the heavy lifting once per second, regardless of how many people are watching.
🔮 The Potential: What else can you build?
This isn't just for Bitcoin prices. Because this output is a standard image, it works in places where JavaScript is banned—like Email Clients and Markdown files.
Here are a few "efficient" ideas you could build with this:
- Dynamic Email Signatures: A banner in your footer that shows your latest blog post title or your company’s current uptime status.
- The "Hiring" Badge: A badge on your repo that turns Green when you are "Open to Work" and Red when you are busy, synced to your calendar.
- Event Countdowns: A "Time until Launch" clock that embeds directly into your newsletter.
- Spotify Visualizer: Connect to the Spotify API to show what you are listening to right now on your profile.
📦 Try it out
I open-sourced the whole thing. It’s a clean boilerplate—you can clone it, change the drawing logic to show your own stats (or just a generic "Matrix" rain), and deploy.
Repo: https://github.com/sunanda35/Infinite-GIF
If you find it cool, drop a generic ⭐️ on the repo. It feeds my dopamine.
Top comments (0)