
Terminal output during local dev is boring. A 500 error scrolls by as a single dim line and you barely notice it. A 429 from a rate limiter looks identical to a successful 200. Your server is quietly screaming and your log file is a wall of near-identical text.
I got fed up with this over a weekend and built roastttp โ a tiny CLI and Express middleware that reacts to HTTP status codes with dry, dev-sarcastic one-liners and ASCII art.
npx roastttp https://httpbin.org/status/500
POST /api/payment โ 500 INTERNAL SERVER ERROR ยท 2341ms
( . ) ( )
) ( )
. ' . ' .
( , ) (. )
). , ( . ( ) ( , ')
(_,) . ), ) _) _,') (, )
โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
SOMETHING IS ON FIRE
๐ฅ INTERNAL SERVER ERROR (าโก_โก)
"somewhere, a try/except ate an error and pretended everything was fine"
It's up on npm at 20KB with zero runtime dependencies: npm install roastttp.
The design rabbit hole
The first version of this in my head was "GIFs for each status code." I spent a full evening researching how to render GIFs in a terminal before realizing three things:
One, terminal image protocols (iTerm2, Kitty, Sixel) render static images, not animated GIFs. Even in a modern terminal you'd be showing a single frame. So why fetch a GIF at all?
Two, Tenor's API is being sunset by Google in September 2026. Giphy's free tier is rate-limited to 100 requests per hour and requires a "Powered by GIPHY" attribution badge, which is weird to render in a terminal. Both platforms' ToS explicitly restrict commercial use of bundled content.
Three, even if I solved those, I'd still be shipping other people's content and hoping nobody's lawyer notices. Indie project, fragile foundation.
The conclusion was obvious in retrospect: go all-in on text. ASCII art, emojis, kaomoji, and hand-written one-liners. It's universal, it's legal, it's distinctive, and honestly it's funnier than a Drake reaction GIF in 2026.
The voice
This was the hardest part, and the most important. My first pass had Gen Z internet voice โ "giving 500 energy", "and I don't know her", "bestie." I showed it to a dev friend and she said it didn't land. It felt like a marketing team impersonating a programmer.
So I rewrote everything in a dev-sarcastic register. Dry, weary, specific to real dev pain. Examples:
404: "check the path. check it again. it's still wrong."
500: "somewhere, a try/except ate an error and pretended everything was fine"
429: "your retry logic does not include backoff. fix that."
503: "503: the status page has not been updated. it never is."
422: "your payload parsed. your payload is still wrong."
The voice rules I codified in CONTRIBUTING.md:
- Punch up at abstractions (stack traces, JIRA, YAML, cache), not at people
- Under 80 characters when possible
- No proper nouns โ no company names, no public figures
- No religion, politics, or identity humor
- Dev-specific beats generic โ "Redis is tired. Redis needs a minute." beats "server busy, try later."
This is the kind of thing you can't A/B test; you just write a hundred lines, delete eighty, and trust your ear.
The technical decisions
A handful of small choices that I think made this punch above its weight:
Zero runtime dependencies. The whole thing is TypeScript compiling to a single dist/ folder. ANSI colors are done by hand (it's ~30 lines). Arg parsing is done by hand. This felt excessive at first but it means npm install roastttp is under 100ms and the package is 20KB instead of a quarter megabyte.
Catalog as JSON, not code. The reactions live in src/data/reactions.json, not in TypeScript. This way people who want to contribute roast lines never have to touch TypeScript โ they submit a one-line diff to a JSON file. Turning contribution friction to near-zero matters a lot for humor projects where the community is the product.
Three-tier rendering. Light codes (2xx, 3xx) get a single line. Medium codes (4xx) get a small ASCII box. Heavy codes (5xx) get a full dramatic scene. This scales visual weight to how catastrophic the error actually is, which matches how developers emotionally experience them anyway.
Structural typing for the Express adapter. I didn't want @types/express as a dependency. So the middleware's ReqLike and ResLike interfaces are structural โ they match both Node's raw http.IncomingMessage and Express's Request. Zero install cost, works in both contexts.
interface ReqLike {
method?: string;
originalUrl?: string;
url?: string;
}
interface ResLike {
statusCode: number;
on(event: 'finish' | 'close', cb: () => void): void;
}
Strict TypeScript everywhere. noUncheckedIndexedAccess caught two real bugs during development: accessing .label on a fallback reaction that didn't have one, and indexing into the roasts array without null-checking. Strict mode earns its keep.
The Express middleware
This is where I think roastttp gets actually useful, not just funny. One line, your existing access logs are untouched, reactions only fire on 4xx and 5xx by default:
import express from 'express';
import { roastttp } from 'roastttp/express';
const app = express();
app.use(roastttp());
app.get('/ok', (_req, res) => res.send('ok'));
app.get('/boom', (_req, res) => res.status(500).send('bad day'));
Options for when you want to tune it:
app.use(roastttp({
reactOn: [404, 500], // specific codes, or a predicate
rarity: 0.3, // 30% chance โ prevents habituation in busy servers
silent2xx: true, // default: don't roast successes
}));
The rarity knob surprised me with how much it helped. If every 500 roasted, I'd mentally filter them out within an hour. At 30% rarity they stay novel, which is the whole point.
What I'm not building (yet)
I got the ship-lean discipline from a previous project. A week of polishing pre-launch is a week of not learning what people actually want. So roastttp v0.1 shipped deliberately incomplete:
-
No Next.js adapter yet. Next.js's logger requires monkey-patching (see
next-logger), which is fragile across Next versions. Waiting for demand before taking on that maintenance burden. - No sound effects. Terminal beeps are jarring, break SSH/CI, and would require a native dependency. The aesthetic is quiet, dry humor.
- No AI-generated dynamic roasts. Tempting (I've built tools that call the Claude API for dynamic text) but ships complexity on day one. The hand-written catalog is fine.
- No theme/meme packs. Planned for v0.2 as a paid tier โ "pirate mode," "shakespeare mode," "Nepali-localized mode." But v0.1 ships with the one canonical voice.
Try it
# zero-install try
npx roastttp https://your-api.com
# preview the full gallery
npx roastttp --preview
# render a specific code without a network call
npx roastttp --code 418
Repo: github.com/clashrelated/roastttp
PRs for new roast lines are welcome โ it's a one-line JSON diff, and the CONTRIBUTING guide covers the voice rules. If you ship it in your own project, tag me. I want to see the wild places this ends up.
United States
NORTH AMERICA
Related News
![[Tutorial] Building an Unshielded Token dApp with UI](/_next/image?url=https%3A%2F%2Fmedia2.dev.to%2Fdynamic%2Fimage%2Fwidth%3D800%252Cheight%3D%252Cfit%3Dscale-down%252Cgravity%3Dauto%252Cformat%3Dauto%2Fhttps%253A%252F%252Fdev-to-uploads.s3.amazonaws.com%252Fuploads%252Farticles%252Fiej2avrvzu0wic5jpmvq.png&w=3840&q=75)
[Tutorial] Building an Unshielded Token dApp with UI
12h ago
Techn0tz Turns 1 โ Help Shape Whatโs Next
22h ago
Capture a Flutter Widget as PNG and Download It โ Web Share Card
17h ago
โ ๏ธ Race Conditions in APIs - The Bug You Canโt See
18h ago
Idempotency explained โ Part 1: basics, idempotency key and Go implementation
18h ago
