A lightweight, lag-free desktop widget that shows today's spend, today's token usage, and the current 5-hour block in real time.

Top bar: ✳ $139.95 · 550.8k · 23.3k/290k · 4h41m
Top bar (right): today's cumulative cost · today's tokens · current block usage / cap · block time remaining
TL;DR — A GNOME Shell extension (executor@raujonas.github.io) displays ccusage output in the top bar on a timer. The trick: ccusage runs in a background systemd user timer and writes its result to a tmpfs cache file; the top-bar widget only reads the file. The widget script itself takes ~8 ms; the gnome-shell main loop does zero slow work, so keyboard input stays buttery. Four files, user-level systemd, zero sudo, trivially uninstallable.

1. Symptoms

  • A single keypress produces multiple characters (iiiii)
  • Global lag — half a second between keystroke and screen update
  • Every window is affected (terminal, IDE, browser), not just one app
  • System load is low (2–4), CPU mostly idle, no memory / I/O / GPU pressure

2. Root cause

The problem isn’t that “ccusage is slow.” The problem is that the slow work is happening in the wrong place.

The GNOME Shell extension Executor runs a command on a timer and shows its output in the top bar. It was set to run ~/.config/argos/claude_topbar.sh every 5 seconds. That script calls ccusage (an npm tool that tallies Claude Code token usage) twice:

CallDurationWhy it's slow
ccusage -j~5.4 sCold-starts Node v22 + loads all deps every run
Walks 280 JSONL files / 192 MB under ~/.claude/projects/ (largest single file: 59 MB)
Line-by-line JSON.parse to accumulate tokens — no internal cache, full re-scan every time
ccusage blocks -j~4.4 s
jq × 6 + bc etc.~0.5 sEach subprocess is a fork+exec
Total~14.9 s 

The fatal arithmetic:

Executor interval:  5 s
Script duration:    14.9 s
→ 2–3 ccusage processes always running in parallel
→ Each pinning a core + a lot of small-file I/O

Why this lags the entire desktop

Executor runs inside the gnome-shell process. gnome-shell is a single-threaded main loop responsible for X event dispatch, window compositing, animations, and extension logic across the entire desktop. Executor uses GLib to fork+exec a child and asynchronously wait for its output. Even though it’s “async,” two things still land on the main loop:

  • Every fork() has to copy gnome-shell’s page tables — the larger gnome-shell gets, the slower the copy
  • The child’s stdout callback (read, parse, update label) runs on the main loop

Every time the main loop stutters, every window’s keyboard event dispatch is delayed. Once that delay exceeds tens of milliseconds, X11 autorepeat misfires — that’s the “one keystroke became iiiii” you see.

3. The fix: decouple at the architecture layer

Don’t try to make ccusage fast. Just move it off gnome-shell’s critical path.

Before — synchronous path

Executor fires (every 5s)
  fork+exec bash → claude_topbar.sh
     ├── ccusage -j           ← 5.4s (scan 280 JSONLs)
     ├── ccusage blocks -j    ← 4.4s
     ├── jq × 6 + bc          ← ~0.5s
  Returns string → Executor updates top bar    Total: 14.9s

After — async path

[background, completely independent of Executor]
systemd timer ─every 60s─→ ccusage runs ~6s ─→ writes /run/user/UID/ccusage_topbar.txt
                                                  46-byte text file
                                                  (tmpfs, in RAM)
Executor fires (every 5s) ──→ fork+exec bash on new script ┘   Total: 8ms
                              ├── stat file mtime  ← μs
                              └── cat file         ← μs

Why not just cache inside ccusage?

ApproachCaches whatSpeedupUsed?
ccusage -O / --offlineClaude model pricing (skips API lookup)14.9 s → 6.2 sYes (nice-to-have)
ccusage --since YYYYMMDDDate filterNoneNo
measured: parses everything then filters — doesn't skip files
ccusage statusline (beta)Scan results + file fingerprint
(hybrid time+file cache)
~64 msNo
built for the Claude Code hook — needs session context on stdin; bare invocation errors with No input provided
tmpfs file + systemd timerThe fully-rendered display string (46 bytes)14.9 s → 8 msPrimary fix

The first three are optimizations inside ccusage. Even if they returned instantly, the fork+exec itself still happens on the gnome-shell main loop. The tmpfs-file approach is an architectural decoupling — gnome-shell only ever cats a small file in RAM, so even if ccusage gets slower, crashes, or gets OOM-killed, the desktop notices nothing.

4. Implementation

4.1 Background script — run ccusage, write to tmpfs

File: ~/.local/bin/ccusage-topbar-refresh.sh

#!/bin/bash
# Runs ccusage in the background and writes the rendered top-bar string to a tmpfs cache file.
# Driven by a systemd user timer; Executor only reads this file.

set -u
export PATH="/usr/bin:/bin:/usr/local/bin:$HOME/.nvm/versions/node/v22.22.2/bin:$PATH"

LIMIT_K=290
OUT="/run/user/$(id -u)/ccusage_topbar.txt"
TMP="${OUT}.tmp"

DAILY=$(ccusage -j -O 2>/dev/null | jq -r '.daily[-1]')
COST=$(echo "$DAILY" | jq -r '.totalCost // 0')
TODAY_IN=$(echo "$DAILY" | jq -r '.inputTokens // 0')
TODAY_OUT=$(echo "$DAILY" | jq -r '.outputTokens // 0')
TODAY_REAL=$((TODAY_IN + TODAY_OUT))

if [ "$TODAY_REAL" -gt 1000000 ]; then
    TOK_STR=$(printf "%.2fM" "$(echo "scale=2; $TODAY_REAL/1000000" | bc -l)")
else
    TOK_STR=$(printf "%.1fk" "$(echo "scale=1; $TODAY_REAL/1000" | bc -l)")
fi

BLOCK=$(ccusage blocks -j -O 2>/dev/null | jq -r '[.blocks[] | select(.isActive == true)][0]')

if [ -z "$BLOCK" ] || [ "$BLOCK" = "null" ]; then
    LINE=$(printf "✳️ \$%.2f · %s · idle" "$COST" "$TOK_STR")
else
    B_IN=$(echo "$BLOCK" | jq -r '.tokenCounts.inputTokens // 0')
    B_OUT=$(echo "$BLOCK" | jq -r '.tokenCounts.outputTokens // 0')
    B_REAL=$((B_IN + B_OUT))
    B_REMAIN_MIN=$(echo "$BLOCK" | jq -r '.projection.remainingMinutes // 0')
    H=$((B_REMAIN_MIN / 60))
    M=$((B_REMAIN_MIN % 60))
    TIME_STR="${H}h${M}m"
    USED_K=$(echo "scale=1; $B_REAL/1000" | bc -l)
    LINE=$(printf "✳️ \$%.2f · %s · %sk/%dk · %s" "$COST" "$TOK_STR" "$USED_K" "$LIMIT_K" "$TIME_STR")
fi

# Atomic write: write to tmp then rename, so Executor never reads a half-written file
printf "%s" "$LINE" > "$TMP" && mv "$TMP" "$OUT"

4.2 systemd user service (oneshot)

File: ~/.config/systemd/user/ccusage-topbar.service

[Unit]
Description=Refresh ccusage topbar cache file

[Service]
Type=oneshot
ExecStart=%h/.local/bin/ccusage-topbar-refresh.sh
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7

4.3 systemd user timer — 60 s interval, never overlapping

File: ~/.config/systemd/user/ccusage-topbar.timer

[Unit]
Description=Periodically refresh ccusage topbar cache

[Timer]
OnBootSec=10s
OnUnitInactiveSec=60s
AccuracySec=5s
Unit=ccusage-topbar.service

[Install]
WantedBy=timers.target

OnUnitInactiveSec=60s means: 60 s after the previous service run finishes (becomes inactive), the next one fires. While the oneshot is running it’s active, so the timer can’t fire again — runs serialize naturally. Even if ccusage occasionally takes 70 seconds, nothing overlaps.

4.4 Top-bar script — just cats the file

File: ~/.config/argos/claude_topbar.sh

#!/bin/bash
# Executor top-bar script — reads the cache file maintained by the systemd timer. <10ms total.

CACHE="/run/user/$(id -u)/ccusage_topbar.txt"

if [ ! -r "$CACHE" ]; then
    printf "⌛ ccusage…"
    exit 0
fi

# If the cache is stale (>5 min), surface that instead of pretending all is well
AGE=$(( $(date +%s) - $(stat -c %Y "$CACHE") ))
if [ "$AGE" -gt 300 ]; then
    printf "⚠️ ccusage stale %ds · " "$AGE"
fi

cat "$CACHE"

4.5 Enable

chmod +x ~/.local/bin/ccusage-topbar-refresh.sh
systemctl --user daemon-reload
systemctl --user enable --now ccusage-topbar.timer
systemctl --user start ccusage-topbar.service   # generate first cache immediately

# verify
systemctl --user list-timers ccusage-topbar.timer
journalctl --user -u ccusage-topbar.service -n 20

5. Results

MetricBeforeAfter
Top-bar script duration14.9 s8 ms
gnome-shell main loop occupiedevery 5 s, blocked for 6 s+every 5 s, milliseconds
Overlapping ccusage runsroutinely 2–3 in parallelimpossible (timer serializes)
Top-bar refresh cadencebottlenecked by ccusagedecoupled — 5 s read / 60 s compute
When ccusage breaksdesktop lagstop bar shows ⚠️ ccusage stale Xs ·, desktop untouched

6. Where this pattern applies — and three lessons

This isn’t ccusage-specific. The pattern applies whenever a status-bar / top-bar widget periodically renders the result of a slow external command:

  • tmux / sketchybar / waybar / polybar / xmobar calling git status, kubectl get, network probes, cloud status, etc.
  • VSCode / JetBrains status-bar plugins running scripts on a timer
  • Any “GUI process forks a possibly-slow child every N seconds” design

Three takeaways:

  1. Don’t run external commands on a UI main loop, no matter how “usually fast” they are. The day something in their environment changes, the UI takes the hit.
  2. The timer interval must exceed the worst-case task duration, or overlapping runs just stack up. systemd’s OnUnitInactiveSec pattern is naturally overlap-free.
  3. Cache files belong in tmpfs (/run/user/UID/) — no disk I/O, wiped on reboot, doesn’t pollute $HOME.

Environment: Ubuntu + GNOME Shell on X11, Executor extension, ccusage v18.0.11. At the time of the incident ~/.claude/projects/ held 280 JSONL session files totaling 192 MB — the reason a single ccusage cold start took 5+ seconds.