<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sheryar Ahmed</title>
    <description>The latest articles on DEV Community by Sheryar Ahmed (@sheryar_ahmed).</description>
    <link>https://dev.to/sheryar_ahmed</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2307239%2F55509ae9-f4f7-491a-93f4-95188b650c33.jpg</url>
      <title>DEV Community: Sheryar Ahmed</title>
      <link>https://dev.to/sheryar_ahmed</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sheryar_ahmed"/>
    <language>en</language>
    <item>
      <title>When something breaks across 20 microservices, one question decides how fast you recover</title>
      <dc:creator>Sheryar Ahmed</dc:creator>
      <pubDate>Mon, 03 Aug 2026 17:15:15 +0000</pubDate>
      <link>https://dev.to/sheryar_ahmed/when-something-breaks-across-20-microservices-one-question-decides-how-fast-you-recover-36bc</link>
      <guid>https://dev.to/sheryar_ahmed/when-something-breaks-across-20-microservices-one-question-decides-how-fast-you-recover-36bc</guid>
      <description>&lt;h1&gt;
  
  
  It's 2 A.M. and Your Dashboard Just Says "Something Is Wrong"
&lt;/h1&gt;

&lt;p&gt;A field guide to observability — logs, metrics, and traces — for everyone who has ever debugged twenty microservices with grep and prayer.&lt;/p&gt;




&lt;p&gt;It's 2:14 a.m. Your phone is doing the thing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frjtf59k7mkulgts2opa2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frjtf59k7mkulgts2opa2.jpg" alt=" " width="736" height="414"&gt;&lt;/a&gt;&lt;br&gt;
                 Caption: "on-call, living the dream."&lt;/p&gt;

&lt;p&gt;Checkout is failing. Not for everyone, not all the time, but enough that support is drowning and someone has already tweeted a screenshot with the words "is this app down or is it just me." You open your laptop, one eye still asleep, and pull up the dashboard.&lt;/p&gt;

&lt;p&gt;A big red number is climbing. Error rate: up. Something is wrong.&lt;/p&gt;

&lt;p&gt;And that is &lt;em&gt;all it tells you.&lt;/em&gt; Something.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa41z4ou86nbmj0qxrdu4.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa41z4ou86nbmj0qxrdu4.jpeg" alt=" " width="736" height="558"&gt;&lt;/a&gt;&lt;br&gt;
Caption: "Dashboard: 'Something is wrong.' Me: great, love that for us, very specific."*&lt;/p&gt;

&lt;p&gt;Here's the trap you're standing in. Your system isn't one program anymore. It's twenty services, spread across a cluster, written by different people in different languages, calling each other in a chain you couldn't draw from memory if your life depended on it. One "place order" click quietly fans out into a dozen network hops. One of them is failing. Maybe two. Your dashboard knows the house is on fire. It has absolutely no idea which room.&lt;/p&gt;

&lt;p&gt;So you do what everyone does at 2 a.m. You start SSH-ing into boxes. You grep logs. You guess. "Probably the payment service." You read a thousand lines and find nothing. "The database?" Fine. The clock ticks, users keep failing to check out, and somewhere in that chain of twenty services the actual problem is sitting there, arms crossed, invisible, waiting for you to trip over it.&lt;/p&gt;

&lt;p&gt;This — this exact moment — is what observability is &lt;em&gt;for.&lt;/em&gt; Not to look impressive on a screen. To answer one question, fast: &lt;strong&gt;where is the problem, and why?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Let me actually teach you how it works, start to finish, so that nights like this stop being three-hour horror movies and start being ninety-second fixes. No prior knowledge needed. Just bring the caffeine.&lt;/p&gt;
&lt;h2&gt;
  
  
  First, a distinction that everything else hangs on
&lt;/h2&gt;

&lt;p&gt;People throw around "monitoring" and "observability" like they mean the same thing. They don't, and the gap between them is the whole story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring is the smoke alarm.&lt;/strong&gt; You decided &lt;em&gt;in advance&lt;/em&gt; what to watch — error rate, CPU, latency — and built a dashboard for it. It's brilliant at one job: screaming when a known thing goes wrong. "There is smoke." But a smoke alarm cannot tell you that the wiring in the upstairs bathroom shorted out. It only knows: smoke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Observability is being able to walk through the burning house and ask questions you never planned for.&lt;/strong&gt; "Why are only Android users, only in one region, only after 6 p.m., only when paying by card, getting slow checkouts?" Nobody builds a dashboard that specific ahead of time. But if your system is constantly describing itself — emitting enough signal — you can start at the alarm and follow the smoke all the way to the one shorted wire, &lt;em&gt;after&lt;/em&gt; the fire starts, without shipping new code to investigate.&lt;/p&gt;

&lt;p&gt;Monitoring tells you the house is on fire. Observability tells you which room. You need both, but at 2 a.m., the alarm was never the hard part. Finding the room is.&lt;/p&gt;

&lt;p&gt;And to find the room, you need three different kinds of signal. Each answers a different question, and the entire skill is knowing which one to grab.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three signals (a.k.a. your detective kit)
&lt;/h2&gt;

&lt;p&gt;Treat your broken checkout like a crime scene. You've got three tools, and they are not interchangeable.&lt;/p&gt;
&lt;h3&gt;
  
  
  Metrics — the vital-signs monitor
&lt;/h3&gt;

&lt;p&gt;Metrics are numbers over time: requests per second, error rate, latency. Cheap, always running, glanceable. Metrics are what spiked and woke you up. Their job is to answer &lt;em&gt;is something wrong, and how bad?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What metrics &lt;em&gt;cannot&lt;/em&gt; do is tell you &lt;em&gt;which&lt;/em&gt; patient or &lt;em&gt;why.&lt;/em&gt; A metric is an aggregate — the heart-rate line jumping — and it will never name the one request that died, because to stay cheap it blended that request into a million others. That's not a bug. That's the deal: you traded per-request detail for a signal that runs 24/7 and costs almost nothing.&lt;/p&gt;

&lt;p&gt;If you only ever watch a handful of metrics, watch these four — the industry calls them the &lt;strong&gt;Four Golden Signals&lt;/strong&gt;: &lt;strong&gt;latency&lt;/strong&gt; (how slow), &lt;strong&gt;traffic&lt;/strong&gt; (how much demand), &lt;strong&gt;errors&lt;/strong&gt; (how many are failing), and &lt;strong&gt;saturation&lt;/strong&gt; (how full your system is). Those four are the vital signs of basically any service. Get them on a dashboard and you have honest, always-on vitals.&lt;/p&gt;
&lt;h3&gt;
  
  
  Traces — the detective tailing the suspect through the building
&lt;/h3&gt;

&lt;p&gt;A trace is the story of &lt;em&gt;one single request&lt;/em&gt; as it travels across all your services, broken into timed steps called &lt;strong&gt;spans&lt;/strong&gt;. The gateway received it (span one). The gateway called orders (span two). Orders called payments (span three). Each span has a start, a duration, and a parent. Lay them out and you get a &lt;strong&gt;waterfall&lt;/strong&gt; — a visual timeline of exactly where that one request spent its time, and exactly where it fell over.&lt;/p&gt;

&lt;p&gt;When your metrics scream "checkout is slow," the trace is what walks you down the hallway, past the five services that were perfectly fine, and stops you at the door of the one that wasn't. Traces answer the question metrics can't: &lt;em&gt;where?&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Logs — the security-camera footage
&lt;/h3&gt;

&lt;p&gt;When the trace stops you at the payment service's door, the &lt;strong&gt;log&lt;/strong&gt; tells you what actually happened inside the room: "payment processor declined: connection timed out after 5000ms." Logs are the detailed, event-level story of one specific thing. Richest signal you have, and the most expensive to keep — which is why they're only useful if they're written like &lt;em&gt;data&lt;/em&gt;, not diary entries.&lt;/p&gt;

&lt;p&gt;Here's the difference, because it matters. This is a log written like a diary entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-08-02 02:14  payment failed for bob
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useless. To find every failed payment you're back to grep and regex. Now here's the same event written like data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"level":"error","service":"payments","trace_id":"a1b2c3…","msg":"processor declined","reason":"timeout"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That you can &lt;em&gt;query&lt;/em&gt;: "give me every error from the payments service in the last hour." The rule for logs, then, is short: log JSON, one event per line, and put a trace ID in every single one. (Hold onto that trace ID. It's about to become the hero of the story.)&lt;/p&gt;

&lt;p&gt;So: &lt;strong&gt;metrics&lt;/strong&gt; say &lt;em&gt;is it broken.&lt;/em&gt; &lt;strong&gt;Traces&lt;/strong&gt; say &lt;em&gt;where.&lt;/em&gt; &lt;strong&gt;Logs&lt;/strong&gt; say &lt;em&gt;what.&lt;/em&gt; Three tools, three questions, one investigation.&lt;/p&gt;

&lt;p&gt;Now for the part almost every tutorial skips — the three ideas that separate people who merely &lt;em&gt;own&lt;/em&gt; dashboards from people who can actually &lt;em&gt;use&lt;/em&gt; them. They're all a little counterintuitive, and once they click, you will never look at a system the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Truth #1: Averages are filthy liars
&lt;/h2&gt;

&lt;p&gt;Say your checkout handles a hundred requests. Ninety-nine come back in 100 milliseconds. One takes ten full seconds.&lt;/p&gt;

&lt;p&gt;The average? About 200 milliseconds. You glance at it and think: healthy. Ship it.&lt;/p&gt;

&lt;p&gt;But one in every hundred customers just waited &lt;em&gt;ten seconds&lt;/em&gt; for a page. At scale that's thousands of furious people a day, and your dashboard smiled the entire time. The average didn't lie by being wrong — it lied by being an &lt;em&gt;average.&lt;/em&gt; It smeared one catastrophe across ninety-nine good experiences until it vanished.&lt;/p&gt;

&lt;p&gt;There's an old line: a statistician drowned crossing a river that was, on average, three feet deep.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3kwxxxj9nkz3k7nl5wop.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3kwxxxj9nkz3k7nl5wop.jpeg" alt=" " width="736" height="596"&gt;&lt;/a&gt;&lt;br&gt;
Caption: "the average response time is fine, sir."&lt;/p&gt;

&lt;p&gt;This is why people who run big systems almost never look at averages. They look at &lt;strong&gt;percentiles.&lt;/strong&gt; The &lt;strong&gt;p50&lt;/strong&gt; (median) is your typical experience. The &lt;strong&gt;p95&lt;/strong&gt; is what &lt;em&gt;most&lt;/em&gt; users feel — only one in twenty is slower. The &lt;strong&gt;p99&lt;/strong&gt; is the painful tail, the one-in-a-hundred where the angry tweets are born. When an engineer says "our p99 blew up," they mean "the average looks fine, but our worst-served users are suffering, and I can &lt;em&gt;see&lt;/em&gt; it." That visibility is the whole difference between a system you &lt;em&gt;think&lt;/em&gt; is healthy and one you &lt;em&gt;know&lt;/em&gt; is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Truth #2: The thing that makes metrics cheap can also blow them to pieces
&lt;/h2&gt;

&lt;p&gt;Metrics are cheap because they're aggregated. But there's a landmine buried in that word, and it takes down real systems.&lt;/p&gt;

&lt;p&gt;Every metric can be sliced by &lt;strong&gt;labels&lt;/strong&gt;: error rate &lt;em&gt;by service&lt;/em&gt;, &lt;em&gt;by status code&lt;/em&gt;, &lt;em&gt;by region&lt;/em&gt;. Each unique combination of labels is a separate line the database must store and hold in memory. Slice by service and status? Maybe sixty lines. Fine. But the second someone adds a label like &lt;code&gt;user_id&lt;/code&gt;, and you have a million users, you've just asked your metrics database to track sixty &lt;em&gt;million&lt;/em&gt; lines. It falls over and dies. This has a name — &lt;strong&gt;cardinality explosion&lt;/strong&gt; — and it's one of the most popular ways teams accidentally nuke their own monitoring.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frdi05b4xrnwu1lpyx2f8.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frdi05b4xrnwu1lpyx2f8.jpeg" alt=" " width="320" height="240"&gt;&lt;/a&gt;&lt;br&gt;
Caption: "me, after adding user_id as a metric label."*&lt;/p&gt;

&lt;p&gt;The lesson is a rule that keeps you alive: labels are for things with a small, bounded set of values — service, status code, region. They are &lt;em&gt;never&lt;/em&gt; for unbounded things — user IDs, emails, request IDs. And this is exactly where the three signals snap together into a system: when you want per-user, per-request detail, you don't cram it into a metric. That's what logs and traces are &lt;em&gt;for.&lt;/em&gt; Every signal has a job. The whole art is not forcing one to do another's.&lt;/p&gt;

&lt;h2&gt;
  
  
  Truth #3: The real magic is the thread that ties all three together
&lt;/h2&gt;

&lt;p&gt;Here's the idea that turns this from "three separate tools" into a superpower.&lt;/p&gt;

&lt;p&gt;Remember that trace ID I told you to keep in every log line? It also rides along on every metric and every span. One string, following one request across every service it touches. And when all three signals carry the same one, something genuinely magical happens.&lt;/p&gt;

&lt;p&gt;You're staring at a metric spike. You click it — and you're looking at an example &lt;em&gt;trace&lt;/em&gt; from that exact moment. You see the payment span glowing red. You click &lt;em&gt;that&lt;/em&gt; — and you're reading the exact &lt;em&gt;log&lt;/em&gt; lines that request produced: "processor timeout." You never opened a terminal. You never guessed. You followed one thread from "something's wrong" all the way to "here's the precise line and the precise reason," across a system written in three languages, in about ninety seconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fai0d750ht4vhdvslu6zn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fai0d750ht4vhdvslu6zn.png" alt=" " width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
Caption: "…all carrying the same trace_id."*&lt;/p&gt;

&lt;p&gt;For that thread to survive, one thing has to work. When service A calls service B, it has to pass the trace ID along, tucked inside the request. There's an open standard for this — a header literally called &lt;code&gt;traceparent&lt;/code&gt; — and every service in the chain reads it and &lt;em&gt;continues the same story&lt;/em&gt; instead of starting a new one. Break that hand-off anywhere (an ancient library, a queue that drops the header) and your one beautiful trace shatters into disconnected fragments and the magic dies.&lt;/p&gt;

&lt;p&gt;The good news is you don't wire this by hand per vendor. &lt;strong&gt;OpenTelemetry&lt;/strong&gt; is the open, vendor-neutral standard for producing all three signals and passing that thread along. You instrument your code &lt;em&gt;once&lt;/em&gt; against it, and you can send the data to any backend you like without rewriting anything. Instrument once, send anywhere, never get locked in. So the entire discipline, in one breath: instrument everything with OpenTelemetry, and never drop the thread.&lt;/p&gt;

&lt;h2&gt;
  
  
  Truth #4 (bonus): Alert on what your &lt;em&gt;users&lt;/em&gt; feel, not what your &lt;em&gt;servers&lt;/em&gt; feel
&lt;/h2&gt;

&lt;p&gt;This one saves your sleep, so it earns a spot.&lt;/p&gt;

&lt;p&gt;You &lt;em&gt;could&lt;/em&gt; alert on a thousand things. Most of them will wake you at 2 a.m. for absolutely nothing. The rule that rescues your sanity: alert on &lt;strong&gt;symptoms your users feel&lt;/strong&gt;, not on internal &lt;strong&gt;causes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;High error rate is a symptom — someone can't check out right now. Wake me. Slow p95 is a symptom — the app feels broken. Wake me. But CPU at 90%? That is &lt;em&gt;not&lt;/em&gt; a symptom. A server pinned at 90% while happily serving fast, successful responses is a server doing its literal job. Page a human for that and you've taught your team that alerts are noise — so the day a real one fires, everyone scrolls past it. CPU and memory are for &lt;em&gt;diagnosis,&lt;/em&gt; something you check &lt;em&gt;after&lt;/em&gt; a symptom already pulled you in. They should never be the thing that pulls you in.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxfga0qtkvqfscgyp1mpc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxfga0qtkvqfscgyp1mpc.png" alt=" " width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And give every alert a little patience: "error rate above 5% &lt;em&gt;for two minutes&lt;/em&gt;," not "for one second." That short wait absorbs the meaningless blips so the only things that ever wake you are real. An alert that isn't worth acting on isn't an alert. It's a notification you'll learn to hate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like when it's all actually running
&lt;/h2&gt;

&lt;p&gt;Concepts are slippery until you watch them move, so let me paint the finished picture.&lt;/p&gt;

&lt;p&gt;Imagine a small but &lt;em&gt;real&lt;/em&gt; distributed system — three services, and on purpose written in two languages: a gateway in Node.js, an orders service in Python, and a payments service back in Node.js. A single request to the gateway fans out through all three, exactly like your checkout. Around them sits the modern stack: Prometheus collecting metrics, Loki holding logs, Tempo storing traces, an OpenTelemetry Collector routing all of it, and Grafana on top as one single pane of glass.&lt;/p&gt;

&lt;p&gt;Send one request, and you watch it become all three signals at once. It shows up as a spike on the metrics dashboard. It shows up as structured log lines. And it shows up as one distributed trace whose waterfall runs from Node, into Python, and back into Node — living proof that the thread survived the trip across languages. Click the trace, jump to its logs. Click a log line, jump back to its trace. That round trip, in one tool, is the entire idea made physical. Watching a single request stitch three languages into one story is the moment observability stops being a buzzword and becomes obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now rewind to 2 a.m.
&lt;/h2&gt;

&lt;p&gt;Same night. Same fire. But this time your system can explain itself.&lt;/p&gt;

&lt;p&gt;Your phone buzzes. Error rate climbing. You open the dashboard — but instead of shrugging at "something is wrong," you click into an example failing trace from that exact minute and watch the waterfall light up the payment span in angry red. You click it. The logs are right there: "payment processor declined: connection timed out." You check the processor's status page — they're having an incident. You flip a feature flag to fail over to your backup provider, checkout recovers, and you are back in bed before the coffee would have finished brewing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgjjgrzaezljpnyh8bzt7.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgjjgrzaezljpnyh8bzt7.jpeg" alt=" " width="500" height="700"&gt;&lt;/a&gt;&lt;br&gt;
Caption: "found it in 90 seconds. good night."&lt;/p&gt;

&lt;p&gt;Same fire. Same twenty services. The difference isn't that you got smarter at 2 a.m. It's that your system could finally tell you where it hurt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start, if you're starting from zero
&lt;/h2&gt;

&lt;p&gt;Don't try to boil the ocean. Do these three, in this order:&lt;/p&gt;

&lt;p&gt;First, put the &lt;strong&gt;Four Golden Signals&lt;/strong&gt; — latency, traffic, errors, saturation — on one dashboard, so you have honest vital signs. Second, make your logs &lt;strong&gt;structured&lt;/strong&gt;: JSON, one event per line, a trace ID in every one, so they're queryable instead of grep-bait. Third, add &lt;strong&gt;tracing with OpenTelemetry&lt;/strong&gt;, so you can follow one request across every service and jump between all three signals.&lt;/p&gt;

&lt;p&gt;Do that, and the next time something breaks across your services, you won't be the person SSH-ing into boxes and guessing at 2 a.m. You'll be the person who clicks three times, points at the exact failing service, says "found it," and goes back to sleep while everyone else is still reaching for the terminal.&lt;/p&gt;

&lt;p&gt;That, in the end, is all observability really is: giving your system a voice, so that when it's hurting, it can finally just &lt;em&gt;tell you where.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you'd like to see all of this running — the three services, the cross-language trace, every signal lighting up in Grafana — I built the whole thing as a hands-on demo and walk through it live. Even better, it runs with one command, so you can break it on purpose and watch it tell on itself. Link's in the comments.&lt;/em&gt;&lt;/p&gt;




</description>
      <category>webdev</category>
      <category>devops</category>
      <category>automation</category>
      <category>cloud</category>
    </item>
    <item>
      <title>"an AI-interview experience with zero AI and zero cost." Opens with the three bills you didn't pay (TTS, Whisper, proctoring vendor)</title>
      <dc:creator>Sheryar Ahmed</dc:creator>
      <pubDate>Tue, 07 Jul 2026 20:57:07 +0000</pubDate>
      <link>https://dev.to/sheryar_ahmed/an-ai-interview-experience-with-zero-ai-and-zero-cost-opens-with-the-three-bills-you-didnt-pay-1jmm</link>
      <guid>https://dev.to/sheryar_ahmed/an-ai-interview-experience-with-zero-ai-and-zero-cost-opens-with-the-three-bills-you-didnt-pay-1jmm</guid>
      <description>&lt;h1&gt;
  
  
  A mock interviewer that speaks, listens, and proctors with no AI bill
&lt;/h1&gt;

&lt;p&gt;Demo: &lt;a href="https://drive.google.com/file/d/13WEiJXqwq7O6XzouhUR0LpNfgz67znd9/view?usp=sharing" rel="noopener noreferrer"&gt;https://drive.google.com/file/d/13WEiJXqwq7O6XzouhUR0LpNfgz67znd9/view?usp=sharing&lt;/a&gt;&lt;br&gt;
Mentors on my platform wanted to run practice interviews with their mentees: a voice&lt;br&gt;
asks a question, the candidate answers out loud, it gets recorded, and — because it's&lt;br&gt;
practice for the real thing the session is proctored. Every off-the-shelf version of&lt;br&gt;
this hands you three metered bills: a &lt;strong&gt;TTS&lt;/strong&gt; service to read the question, an &lt;strong&gt;STT&lt;/strong&gt;&lt;br&gt;
service (usually Whisper) to transcribe the answer, and a proctoring vendor to watch the&lt;br&gt;
room. Three subscriptions for a feature people use in short bursts.&lt;/p&gt;

&lt;p&gt;The naive plan is to just pay them. Wire up a cloud TTS voice, POST each audio answer to&lt;br&gt;
a speech-to-text endpoint, embed a proctoring SDK, and eat the per-minute cost forever.&lt;br&gt;
It works, but every one of those is a recurring charge, a data-egress path, and a privacy&lt;br&gt;
footprint — for something that's fundamentally &lt;em&gt;the candidate's own browser talking to&lt;br&gt;
the candidate's own microphone&lt;/em&gt;. I didn't want a network round-trip sitting between a&lt;br&gt;
mentee and a practice question.&lt;/p&gt;
&lt;h2&gt;
  
  
  The decision
&lt;/h2&gt;

&lt;p&gt;I asked how much of the stack the browser already ships, and the answer was: nearly all&lt;br&gt;
of it. The whole "AI interviewer" experience is three native Web APIs in a trench coat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TTS — the interviewer speaks.&lt;/strong&gt; &lt;code&gt;window.speechSynthesis&lt;/code&gt; renders the question on-device.&lt;br&gt;
The mentor even tunes the pitch/rate so the interviewer ("Aria") has a personality. No&lt;br&gt;
audio is generated on a server; nothing is downloaded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;STT — the answer transcribes itself.&lt;/strong&gt; &lt;code&gt;SpeechRecognition&lt;/code&gt; (&lt;code&gt;webkitSpeechRecognition&lt;/code&gt;&lt;br&gt;
in Chromium) does live, &lt;strong&gt;streaming&lt;/strong&gt; transcription on-device — &lt;code&gt;interimResults&lt;/code&gt; means&lt;br&gt;
words land on screen as they're spoken, the same engine behind your phone keyboard's mic&lt;br&gt;
button. No upload, no Whisper invoice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audio streaming — the recording is the ground truth.&lt;/strong&gt; &lt;code&gt;MediaRecorder&lt;/code&gt; captures the mic&lt;br&gt;
to a chunked &lt;code&gt;audio/webm&lt;/code&gt; blob. That blob is the &lt;em&gt;only&lt;/em&gt; byte that touches my server, and&lt;br&gt;
it goes to storage I already pay for.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// TTS: the interviewer reads the prompt (mentor-tuned voice)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SpeechSynthesisUtterance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;interviewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pitch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;interviewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pitch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;speechSynthesis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;            &lt;span class="c1"&gt;// never overlap two questions&lt;/span&gt;
&lt;span class="nx"&gt;speechSynthesis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;speak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// STT: live, streaming transcript — no server in the loop&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SpeechRecognition&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webkitSpeechRecognition&lt;/span&gt;&lt;span class="p"&gt;)();&lt;/span&gt;
&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;continuous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interimResults&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onresult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTranscript&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Audio streaming: MediaRecorder is the ground truth if STT is unsupported&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MediaRecorder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;audio/webm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;mr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ondataavailable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key judgment call is &lt;em&gt;layering&lt;/em&gt;, not just "use the free thing." STT support is uneven&lt;br&gt;
(Firefox notably lags), so transcription is never the source of truth — the &lt;strong&gt;recording&lt;/strong&gt;&lt;br&gt;
is. STT degrades to a nicety; the feature never breaks. And a green-room mic check runs a&lt;br&gt;
lightweight &lt;strong&gt;VAD&lt;/strong&gt;-style level meter off the Web Audio &lt;code&gt;AnalyserNode&lt;/code&gt; (RMS on the time-&lt;br&gt;
domain data) so a candidate can &lt;em&gt;see&lt;/em&gt; the mic is live before the clock starts — again,&lt;br&gt;
zero network, pure &lt;code&gt;getByteTimeDomainData&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Tally: TTS $0, STT $0, proctoring $0. The only recurring cost is the audio blob storage I&lt;br&gt;
already had.&lt;/p&gt;
&lt;h2&gt;
  
  
  The gotcha
&lt;/h2&gt;

&lt;p&gt;Proctoring is where "free browser signals" nearly bit me. The harness itself is cheap and&lt;br&gt;
honest — fullscreen lock, tab/blur focus-loss counting, copy/paste/right-click blocking,&lt;br&gt;
periodic webcam snapshots, all buffered client-side and flushed in batches. The trap was a&lt;br&gt;
kit option I was quietly proud of: &lt;strong&gt;"require camera."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I shipped it, then during testing found the guarantee was hollow. A candidate could allow&lt;br&gt;
the camera at the start, then &lt;strong&gt;turn it off mid-interview&lt;/strong&gt; from the browser's site&lt;br&gt;
controls. The session sailed on — and worse, my snapshot loop kept firing, dutifully&lt;br&gt;
uploading &lt;strong&gt;black frames&lt;/strong&gt;. On the surface everything looked compliant. "Require camera"&lt;br&gt;
that you can switch off after ten seconds isn't a requirement; it's decoration.&lt;/p&gt;

&lt;p&gt;The fix is one signal: a &lt;code&gt;MediaStreamTrack&lt;/code&gt;'s health. When a camera is turned off, covered,&lt;br&gt;
or unplugged, its track goes &lt;code&gt;muted&lt;/code&gt;/&lt;code&gt;ended&lt;/code&gt; and &lt;code&gt;readyState&lt;/code&gt; stops being &lt;code&gt;'live'&lt;/code&gt;. Poll&lt;br&gt;
that, and two behaviors fall out of the same check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;video&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;srcObject&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;MediaStream&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;getVideoTracks&lt;/span&gt;&lt;span class="p"&gt;?.()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;muted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// (1) block instead of silently continuing&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;setCameraLive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;camera_off&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// → full-screen "turn it back on" overlay&lt;/span&gt;

&lt;span class="c1"&gt;// (2) never upload a black frame from a dead camera&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;muted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// skip the snapshot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now when the camera drops, the runner throws a full-screen block — &lt;em&gt;"your camera is off,&lt;br&gt;
turn it back on to continue"&lt;/em&gt; with a one-click &lt;strong&gt;Retry camera&lt;/strong&gt; — and the candidate can't&lt;br&gt;
answer or advance until it's back. The clock keeps running and the off→on gap is logged,&lt;br&gt;
so turning the camera off to buy thinking time is a &lt;em&gt;recorded&lt;/em&gt; act, not a free one. And the&lt;br&gt;
mentor's proctor gallery gets a clean &lt;strong&gt;"Camera turned off"&lt;/strong&gt; flag instead of a wall of&lt;br&gt;
black rectangles. Same track state, both jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;STT confidence + a manual-correction pass.&lt;/strong&gt; On-device recognition trails Whisper on
accents and noise. I'd surface a confidence score and let the candidate fix the
transcript, keeping the audio as the authority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real VAD, not just a level meter.&lt;/strong&gt; The RMS reading is enough for a "mic is live" cue;
proper voice-activity detection could auto-trim dead air and auto-advance on silence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest limits on proctoring.&lt;/strong&gt; Client-side signals raise the &lt;em&gt;cost&lt;/em&gt; of cheating; they
don't make it impossible (a second device still exists). The goal is deterrence and an
auditable timeline for a mentor — not a courtroom — and the UI should say so.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The meta-lesson: before you add an AI line-item, check what &lt;code&gt;window&lt;/code&gt; already does for free.&lt;br&gt;
The browser quietly grew a whole speech-and-media stack — TTS, streaming STT, &lt;code&gt;MediaRecorder&lt;/code&gt;,&lt;br&gt;
Web Audio VAD, &lt;code&gt;MediaStreamTrack&lt;/code&gt; health — and most of us keep reaching for an API instead.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the last feature you paid a vendor for that the platform could already do TTS,&lt;br&gt;
STT, or something else?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>react</category>
    </item>
    <item>
      <title>I added dark mode by editing CSS variables not 100 components</title>
      <dc:creator>Sheryar Ahmed</dc:creator>
      <pubDate>Tue, 30 Jun 2026 13:21:06 +0000</pubDate>
      <link>https://dev.to/sheryar_ahmed/i-added-dark-mode-by-editing-css-variables-not-100-components-3n8o</link>
      <guid>https://dev.to/sheryar_ahmed/i-added-dark-mode-by-editing-css-variables-not-100-components-3n8o</guid>
      <description>&lt;h1&gt;
  
  
  I added dark mode by editing CSS variables - not 100 components
&lt;/h1&gt;

&lt;p&gt;My app had ~1,200 hardcoded &lt;code&gt;indigo-*&lt;/code&gt; and ~2,600 hardcoded &lt;code&gt;slate-*&lt;/code&gt; class usages across&lt;br&gt;
130-odd components. The brief was small to say and brutal to do: &lt;em&gt;"ship dark mode, and&lt;br&gt;
let me rebrand the accent color whenever I want."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The naive plan is a sweep: open every component, add &lt;code&gt;dark:&lt;/code&gt; variants, swap colors. That's&lt;br&gt;
weeks of work, and worse, it's &lt;em&gt;permanent&lt;/em&gt; work - every new component re-incurs it, and&lt;br&gt;
the design drifts the moment two people touch it. I wanted dark mode to be a property of&lt;br&gt;
the &lt;strong&gt;theme&lt;/strong&gt;, not of every component.&lt;/p&gt;
&lt;h2&gt;
  
  
  The decision
&lt;/h2&gt;

&lt;p&gt;Two ideas, both "edit the tokens, not the markup":&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. One brand token.&lt;/strong&gt; Instead of components knowing the color &lt;code&gt;indigo&lt;/code&gt;, they reference a&lt;br&gt;
semantic &lt;code&gt;brand&lt;/code&gt; scale. I did a one-time codemod - &lt;code&gt;indigo- → brand-&lt;/code&gt; across &lt;code&gt;app/&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;components/&lt;/code&gt;, &lt;code&gt;lib/&lt;/code&gt; (~1,230 mechanical edits, no logic change) - and defined the brand&lt;br&gt;
scale once as CSS variables. Now recoloring the entire app, globally or per-user, is a&lt;br&gt;
variable swap. No component ever needs editing again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--brand-50&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;#EEF4FF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* … */&lt;/span&gt; &lt;span class="py"&gt;--brand-500&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;#0066FF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;--brand-600&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;#0052D6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* … */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c"&gt;/* Tailwind v4: expose them so bg-/text-/border-/ring-brand-* all resolve to the vars */&lt;/span&gt;
&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="nb"&gt;inline&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-brand-500&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--brand-500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--color-brand-600&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--brand-600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c"&gt;/* …the full scale… */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-user accent "vibes" then become trivial - each preset just re-defines that scale under&lt;br&gt;
an attribute selector, so picking one recolors the app instantly with zero component churn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-accent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"emerald"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--brand-500&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;#059669&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;--brand-600&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;#047857&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Dark mode as a token remap, not a component sweep.&lt;/strong&gt; This is the part that surprised&lt;br&gt;
me.&lt;/p&gt;
&lt;h2&gt;
  
  
  The mechanism (and the wrong turn I took first)
&lt;/h2&gt;

&lt;p&gt;My first attempt failed silently. I tried mapping Tailwind's slate scale through my own&lt;br&gt;
indirection in &lt;code&gt;@theme inline&lt;/code&gt; - and text stayed dark in dark mode. Tailwind's &lt;em&gt;default&lt;/em&gt;&lt;br&gt;
&lt;code&gt;slate-*&lt;/code&gt; utilities weren't picking up my override.&lt;/p&gt;

&lt;p&gt;Then I read the compiled CSS, and the trick fell out of it. Tailwind v4 emits its own&lt;br&gt;
palette as real CSS variables, and the utilities reference them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.text-slate-900&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--color-slate-900&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So I don't need to touch a single utility. I just &lt;strong&gt;redefine the slate ramp inside the&lt;br&gt;
&lt;code&gt;.dark&lt;/code&gt; selector&lt;/strong&gt; - reversed, so "dark text" becomes light and "light background" becomes&lt;br&gt;
dark:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.dark&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-slate-900&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#f1f5f9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* was near-black → now near-white text */&lt;/span&gt;
  &lt;span class="py"&gt;--color-slate-700&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#cbd5e1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-slate-200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#3a4659&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* was light border → now a dark border */&lt;/span&gt;
  &lt;span class="py"&gt;--color-slate-50&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="m"&gt;#273345&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c"&gt;/* was page-white → now an elevated surface */&lt;/span&gt;
  &lt;span class="py"&gt;color-scheme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dark&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c"&gt;/* native scrollbars/date pickers follow too */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;text-slate-900&lt;/code&gt;, &lt;code&gt;bg-slate-50&lt;/code&gt;, &lt;code&gt;border-slate-200&lt;/code&gt; in the codebase flips&lt;br&gt;
automatically. Thousands of usages, one block of CSS.&lt;/p&gt;
&lt;h2&gt;
  
  
  The gotcha
&lt;/h2&gt;

&lt;p&gt;A reversed ramp isn't a straight inversion - &lt;strong&gt;surface hierarchy&lt;/strong&gt; breaks if you're naive.&lt;br&gt;
My first ramp made &lt;code&gt;slate-50&lt;/code&gt; darker than my card color, so every "subtle gray" inner box&lt;br&gt;
(inside drawers and cards) suddenly looked like the page bleeding &lt;em&gt;through&lt;/em&gt; the card. The&lt;br&gt;
fix was to treat the low slate shades as &lt;strong&gt;elevated&lt;/strong&gt; surfaces that sit &lt;em&gt;above&lt;/em&gt; the card,&lt;br&gt;
not below it: &lt;code&gt;canvas (#0b1120) &amp;lt; card (#1e293b) &amp;lt; slate-50/100/200&lt;/code&gt;. Dark mode isn't&lt;br&gt;
"flip the colors" - it's "preserve the depth ordering with a dark palette." Miss that and&lt;br&gt;
everything looks flat and muddy.&lt;/p&gt;

&lt;p&gt;A second, smaller one: native &lt;code&gt;&amp;lt;select&amp;gt;&lt;/code&gt; option lists. Components set a dark background on&lt;br&gt;
the control, but the option popup inherited the dark bg &lt;em&gt;without&lt;/em&gt; a light text color -&lt;br&gt;
dark-on-dark, invisible. One global rule fixed all of them at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;select&lt;/span&gt; &lt;span class="nt"&gt;option&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--card&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--card-foreground&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd do next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A no-flash inline script in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; to set the theme before first paint (right now a
provider gates render, which avoids the flash but costs a frame).&lt;/li&gt;
&lt;li&gt;Audit the handful of &lt;em&gt;intentionally&lt;/em&gt; dark elements (a dark CTA, a tooltip) that I had to
pin to fixed colors so the remap couldn't invert them - those are the exceptions that
prove the rule.&lt;/li&gt;
&lt;li&gt;A contrast-ratio check in CI so a future accent preset can't ship an unreadable pair.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The meta-lesson: if changing a visual property means editing many files, the property is&lt;br&gt;
in the wrong layer. Push color and theme into tokens, and "rebrand the app" or "add dark&lt;br&gt;
mode" turns from a sprint into a diff.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's your dark-mode strategy - &lt;code&gt;dark:&lt;/code&gt; everywhere, CSS variables, or something else?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>tailwindcss</category>
      <category>webdev</category>
      <category>frontend</category>
    </item>
  </channel>
</rss>
