Simiyon Vinscent Samuel · Engineering

All posts / Vercel

  • Vercel
  • SVG
  • Upstash Redis
  • GitHub Actions
  • Edge caching

The five Vercel services behind my GitHub profile

Five small Vercel services render the animated cards on my GitHub profile. A 30-palette colour clock keeps them in lockstep across separate servers, a stale-while-revalidate cache keeps them inside GitHub's four-second image-proxy window, Redis holds the state of a live coding mascot, and GitHub Actions bake the contribution Pac-Man.

My GitHub profile is a page of animated cards, and every one of them is an image served by a small service I run on Vercel. Five services render them, a 150-second colour clock keeps all of them on the same palette at the same moment, a Redis-backed mascot knows whether I am coding, and a GitHub Action bakes the contribution Pac-Man. This post builds the system one requirement at a time: how a Markdown file shows live data, how separate servers stay in sync, how each card answers before GitHub's image proxy gives up, and where the design is still rough.

Camo sits between every card and the visitor. Numbered hops 1 to 3 are one card request. The dashed edge is the Pac-Man SVG that Actions commits and the typing service proxies back out with the live palette.
The top of the live GitHub profile: animated name header, typing line, badge row, Spotify card, the Bloub mascot coding, and the contribution streak
The result. The header, typing line, badges, Spotify card and mascot are all separate image requests served by the boxes above.
Every card is a variation of this loop. The stats service is the most involved because it has the slowest upstream. Press play or pick a step.

Understand the problem#

The brief was simple to say and awkward to satisfy: a profile that looks alive and is accurate, with nothing I have to babysit.

Functional requirements#

  1. Show live GitHub, LeetCode and Spotify data on a README.
  2. Animate the cards, with no script.
  3. Make all cards share one colour at any moment, light and dark variants included.
  4. Show whether I am coding right now, with an elapsed timer.
  5. Show contribution activity as a Pac-Man game and keep it current.

Non-functional requirements#

  1. Answer inside Camo's window. About four seconds, then the image is broken.
  2. Be fetched fresh. A cached copy freezes a card on one colour.
  3. Stay in lockstep. Cards that arrive at different times must still look synchronised.
  4. Fail by design. A broken image looks worse than a stale one.
  5. Deploy independently. Five Vercel projects, each shippable alone.
On the board so far
README  -->  Camo (hides visitor, strips headers, ~4 s)  -->  ???
Need: a source of fresh SVG per view, one shared colour, no JS
Constraint: images only

The set-up#

Core entities#

  • Card. One SVG: header, typing line, stats, streak, languages, LeetCode, Spotify, mascot, Pac-Man or footer.
  • Palette. A named colour set with dark and light variants. There are 30.
  • Service. A Vercel project that renders some cards.
  • Upstream source. GitHub, LeetCode, Spotify's proxy, WakaTime and Redis.
  • Session. One editor window reporting coding or idle state.
A card is the join point. A service renders it, an upstream feeds it, and the palette clock colours it.

The interface#

The interface is a set of URLs, one per card kind, with the theme chosen by query string. A theme beginning with dynamic turns on the palette clock.

Text
capsule     /api?type=waving&theme=dynamic_dark&section=header|footer
typing      /?lines=...&theme=dynamic_dark       /api/spotify       /api/pacman?mode=dark|light
stats       /api?username=...   /api/streak   /api/top-langs   /api/badge?metric=repos|code-time|lines
leetcode    /api/card?sections=profile,solved|streak&theme=dynamic_dark
pet         /mascot.svg?id=<public-id>      POST /api/coding-now      POST /api/coding-stopped

The data model#

Only two things persist. The palette file is static and copied into every service. Redis holds mascot state, with a 900 second TTL on live sessions. The badge entry lives in Vercel's shared runtime cache.
Four of these five are the same story. The loop length and slot size set the animation; the deadlines are set by what Camo will wait for.

Build it one requirement at a time#

Requirement 1: live data as an image#

The first service was the stats card in February 2026, derived from the open-source stats generator and reworked for speed. It teaches the basic shape: an image URL, a function that queries an API and returns SVG.

The base loop. Everything later is added around it.
  1. The README holds an <img> inside a <picture>, with light and dark sources.
  2. Camo fetches the URL for the visitor.
  3. The service queries the upstream API and renders SVG.

Requirement 2 and 3: animation, and one colour everywhere#

The engine is a small pure module. It is copied into each service so each project stays independent.
  1. On render, the service calls the engine, which builds keyframes for fill and gradient stop colours.
  2. It also computes the start offset from the current time, and applies it as a negative animation delay.
  3. The SVG carries the style block, and the browser animates it natively.

Requirement 3b: Camo must refetch#

Two levers on a proxy I do not own. Headers for the future, a query parameter for stale copies already held.
  1. Any theme starting with dynamic overrides the default cache header with max-age=0, no-cache, no-store, must-revalidate.
  2. Camo therefore asks again on every view.
  3. When it still holds an old copy, bumping v= in the README URL makes it a new resource.

Requirement 4: stay inside four seconds#

Serve what you have, refresh what you can. A shared cache answers at once; the upstream is refreshed in the background.
  1. The service asks microCache for the data.
  2. On a shared-cache hit it answers immediately and, if the entry is older than 10 minutes, refreshes it in the background.
  3. On a miss it races the upstream fetch against a 2.6 second deadline. If the deadline wins it serves the last good or preseeded data, and the fetch keeps running to fill the cache.

Requirement 5: a coding mascot with state#

The only stateful path. Writes and reads never meet in the editor: the extension writes, the image request reads.
  1. The extension posts coding-now every 300 seconds while I type, with a per-window session id.
  2. The service writes one hash field per window, with a 900 second expiry.
  3. When Camo fetches the image, the service merges the live sessions and draws coding or sleeping.

Requirement 6: the Pac-Man#

Generated hours earlier, coloured live. The action does the slow part; the service does the part that must match the clock.
  1. The workflow builds the contribution graph and commits the dark and light SVGs.
  2. On each view the typing service fetches the committed file, strips its styles and injects the current keyframes.
  3. Camo returns it, on the same clock as every other card.
The middle of the live GitHub profile: contribution streak, GitHub stats card with grade A, top languages, and two LeetCode cards
The stats and LeetCode cards. Streak, stats and languages come from the stats service, the two LeetCode cards from the leetcode service, all on the same palette clock.

Deep dive: one colour clock for five servers#

Start every animation at zero

A card whose server spent four seconds on an upstream API starts four seconds after a card that did not. The colours drift by exactly the difference in latency.

Pick a palette from the clock, hold it still

The first attempts rotated palettes on a schedule and served a single static colour per render. I tried a 10 minute coprime rotation and a fixed master timeline on 31 October, and a per-visitor starting palette from the current minute on 1 November. That last one was committed and reverted twice. Static colours cannot animate, and a start picked per visitor still depends on arrival time.

Start at the wall clock's position when rendered

Every service computes (now / 1000) mod 150 at render time and applies it as a negative animation delay. A card that arrives late starts further along its loop, which cancels the lateness.

Every card derives its start from the wall clock, not from its own arrival. The three arrival times drawn are illustrative.

The generator builds three keyframe tracks, text fill and two gradient stops, with one step per palette and a hold of 82 percent of each slot, so the remaining 18 percent is the blend into the next colour. The static palette in the base SVG is only a first-paint colour for renderers that ignore CSS animation; the keyframes override it. The same engine is copied into capsule, typing, stats and leetcode, and the mascot's pre-rendered cards carry the same cycle in their art.

The sync has assumptions. All services must share the 150 second period and the palette order, and server clocks must be close. Someone viewing at a different moment starts on a different colour, which is the intent.

Deep dive: Camo and cache headers#

The sync only works if Camo refetches on every view. Dynamic endpoints therefore send max-age=0, no-cache, no-store, must-revalidate. Static themes keep short public caching. The services do not all choose the same header, and the differences are deliberate.

No-store is for the cards that animate; short edge caching is for the rest. Spotify and the live mascot use a one second edge cache so a burst of views shares one render.

Getting here took iteration. Beyond the keyframe experiments above, I also bumped a v= parameter through the README (v=2, then v=3) to make Camo drop stale copies. Cache-busting by URL is blunt, but when a proxy you do not own holds an old copy it is the lever that works.

Deep dive: the four-second budget#

Wait for the upstream

Cards time out in Camo whenever the API is slow, which was most of the time for the stats card.

Cache in function memory

I did this first with a 120 second TTL. On Vercel a fetch still running after the response is frozen, so the memory cache almost never filled: every card waited the full deadline and served a fallback.

Shared cache, serve stale, refresh in the background

Last good data comes from Vercel's shared runtime cache, answered at once. A refresh older than 10 minutes runs under waitUntil so it survives the response. A miss races a 2.6 second deadline and falls back to preseeded data.

The slow fetch is not wasted. It is kept alive with waitUntil, so the second visitor gets real data.
Most deadlines sit under Camo's window. The typing service's font fetch lands right on it, and the Pac-Man proxy's abort is longer than Camo waits.

The badge endpoint applies the same idea at smaller scale. It serves a stored badge immediately, refreshes repos every 5 minutes, code time every 10 and lines crafted every hour, and draws each one through shields.io once per distinct value. On a first-ever request it waits 1.5 seconds for a live value, then draws a hard-coded last known one. Lines crafted sums additions across my non-fork repositories and treats GitHub's 202 "still computing" answers as not ready, rather than publishing a low total. The README's code time badge reads WakaTime's public total through shields directly, because WakaTime now rejects API keys outside its editor plugins.

Deep dive: session state for the mascot#

One status value per user

Two windows overwrite each other: closing one flips the card to idle while the other is still coding.

One value, short TTL

Fixes crashed editors, not two windows. Idle from one window still wins over coding in another.

One hash field per window, merged on read

The card reads coding if any live session is coding, and the oldest start wins so the elapsed timer is right. A 900 second TTL on the hash is the crash backstop.

There are no accounts. The extension generates a secret key. The README embeds a public id, the first 32 hex characters of sha256(key), which cannot be reversed to the key, so nobody can spoof my status from the README. The extension posts with Authorization: Bearer <key>, refreshes every 5 minutes while I type, and a missed beat still fits the 15 minute TTL. Closing a window sets only that window's session idle.

Writes and reads meet only in Redis. One pipelined round trip reads all three keys.

last:<id> lives 30 days so the "away for" text stays right after live entries expire. The merge is a pure function, mergeStatus, so it is testable without Redis. The public server is locked to my id with an ALLOWED_IDS allowlist and the README has a deploy-your-own button. The mascot art, Bloub, is generated at build time from my own animation engine and bundled as pre-rendered SVG cards.

Deep dive: GitHub Actions in the profile repo#

The profile repo has two workflows. snake.yml is titled "Generate Contribution Pacman", a name left over from the earlier snake animation. It runs every 6 hours, on manual dispatch, and on pushes that touch the workflow or .github/scripts/. It builds the graph with a third-party action, runs inject-dynamic-pacman.js to rewrite the cells into level-coloured dots with a static first palette, and commits dist/ with a [skip ci] message so it does not trigger itself. waka-stats.yml runs daily at 00:00 UTC and runs update-metrics.js.

Both workflows end in a commit; the typing service turns it into a live card. The Pac-Man SVG is about 300 KB, so the service also compacts it losslessly before serving.

The service-side half exists because the committed SVG is hours old and carries a static colour. The /api/pacman function fetches it from the repository's raw URL, strips the embedded styles, compacts whitespace and long timing lists (about 7 percent smaller, since Camo serves a file this size uncompressed and a late image starts its colour cycle late), and injects the live keyframes. The result carries the render-time offset like every other card.

The final design#

The clock is the new part. Everything else is the base loop repeated per service, with the data source and the failure path chosen to fit each upstream.

One request, one rebuild#

  1. The visitor loads the profile; the browser asks Camo for each card image.
  2. Camo fetches every card from its service, because the dynamic ones are no-store.
  3. The stats service answers from its shared cache, or races GitHub against 2.6 seconds, and refreshes in the background.
  4. The typing service proxies the Spotify card or serves the offline mascot after 3.5 seconds.
  5. The pet service merges the live Redis sessions into coding or sleeping.
  6. Every service stamps its SVG with the wall-clock offset and returns it. The browser plays the animation, and all cards are on the same colour.

To rebuild: one function per card kind, one shared palette file, one offset function, and the right headers. The rest is tuning.

What I'd change#

  • Publish the palette and clock as one package. It is copied across four services now, and a palette edit has to be made in each.
  • Bring the Pac-Man proxy's 6 second abort under Camo's window, or serve a cached copy on timeout.
  • Replace the hard-coded fallback numbers. The stats, language and badge fallbacks are frozen values. A "data unavailable" state would be more honest than slightly stale numbers.
  • Delete the vestigial metrics step from both workflows, and rename snake.yml. The profile repo's CLAUDE.md still describes the old snake animation.
  • Retire the mascot's old Worker. The repository still carries a Cloudflare Worker with a 5 minute TTL from before the Vercel version.

Test yourself#

Why can a README card not simply start its animation when the page loads?

  1. README images cannot animate.
  2. The animation starts when each image is received, and separate servers answer at different speeds.
  3. Camo rewrites CSS.
  4. SVGs only animate in light mode.
B. Without a shared reference, each card's start drifts by its own latency. The wall-clock offset cancels that.

What does a service do with (Date.now() / 1000) % 150?

  1. Chooses which palette to show forever.
  2. Sets the cache TTL.
  3. Rate-limits visitors.
  4. Applies it as a negative animation delay so the card starts where the shared 150 second cycle currently is.
D. A late-arriving card starts further along its loop, which keeps it in step with earlier cards.

Why do dynamic themes send no-store?

  1. Otherwise Camo would keep a cached copy and freeze the card on the colour it first saw.
  2. To make the SVG smaller.
  3. Because Vercel requires it.
  4. To hide the card from search engines.
A. The synchronisation relies on a fresh render for every view.

The stats service caches in function memory first and later in a shared runtime cache with waitUntil. Why?

  1. Memory is more expensive.
  2. The shared cache is encrypted.
  3. On Vercel a fetch still running after the response is frozen, so the memory cache rarely filled and every card waited out its deadline.
  4. GitHub blocks memory caches.
C. waitUntil keeps the background fetch alive so the next visitor gets real data.

Why does the mascot use a hash field per editor window?

  1. Redis hashes are faster than strings.
  2. One window closing must not overwrite another window that is still coding, so the card reads coding if any live session is coding.
  3. The extension cannot send a body.
  4. It lets the README write status.
B. The first version used a single value and two windows overwrote each other.

Which timeout in the services is longer than Camo's window?

  1. The stats data race at 2.6 seconds.
  2. The Spotify upstream at 3.5 seconds.
  3. The Pac-Man proxy fetch at 6 seconds.
  4. The badge first answer at 1.5 seconds.
C. That fetch can outlast Camo, which is why it is on the list of things to change.