<?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: 137Foundry</title>
    <description>The latest articles on DEV Community by 137Foundry (@137foundry).</description>
    <link>https://dev.to/137foundry</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%2F3856342%2F39ac4be7-399f-4f6e-9a32-60abf8a8a324.png</url>
      <title>DEV Community: 137Foundry</title>
      <link>https://dev.to/137foundry</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/137foundry"/>
    <language>en</language>
    <item>
      <title>How to Test Whether Your Alerts Actually Fire Before You Need Them</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Sun, 02 Aug 2026 11:28:04 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-test-whether-your-alerts-actually-fire-before-you-need-them-336p</link>
      <guid>https://dev.to/137foundry/how-to-test-whether-your-alerts-actually-fire-before-you-need-them-336p</guid>
      <description>&lt;p&gt;An alert that silently stops working is worse than no alert, because the team keeps believing they're covered. This happens more often than most teams realize, a threshold gets misconfigured, a notification channel gets renamed, a schema change breaks the field an alert was watching, and nobody finds out until an incident goes unnoticed for hours. Here's how to actually verify alerts work, instead of assuming they do.&lt;/p&gt;

&lt;p&gt;None of the steps below require expensive tooling or a dedicated reliability team. They require deliberately setting aside time to confirm something you'd otherwise just assume, which is exactly the kind of task that's easy to deprioritize until it costs you.&lt;/p&gt;

&lt;p&gt;This gap between assumed coverage and actual coverage is one of the more dangerous blind spots in data automation, because everything looks fine on paper right up until the moment it isn't, and there's rarely a clean signal telling you the alert has quietly stopped working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Inventory What You Think You're Covered For
&lt;/h2&gt;

&lt;p&gt;Before testing anything, list every alert your team currently relies on and what specific failure it's supposed to catch. This sounds basic, but most teams can't produce this list without digging through a monitoring platform's UI, which is itself a sign of how easy it is to lose track of alert coverage as a system grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Trigger a Known, Controlled Failure
&lt;/h2&gt;

&lt;p&gt;For each critical alert, deliberately trigger the exact condition it's supposed to catch, in a staging environment or a carefully scoped production test, and confirm three things: the alert fired, it fired with the expected severity, and it reached the expected destination (the right Slack channel, the right on-call rotation, the right person).&lt;/p&gt;

&lt;p&gt;This catches a surprisingly common failure mode: an alert that technically fires but routes to a channel nobody actively monitors, or to a person who left the on-call rotation months ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Check the Timing, Not Just the Firing
&lt;/h2&gt;

&lt;p&gt;An alert that eventually fires twenty minutes after the triggering condition isn't necessarily broken, but it might not be fast enough for what it's supposed to catch. If an alert is meant to catch a time-sensitive failure, verify the actual delay between the triggering event and the notification landing somewhere a human sees it, not just that it eventually appears in a dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Confirm the Runbook Link Actually Resolves
&lt;/h2&gt;

&lt;p&gt;If your alerts link to a runbook or a specific dashboard, click through during the test. Dead links and stale dashboard queries are one of the most common forms of alert rot, and they're invisible until someone's actually mid-incident trying to use them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Test After Any Significant Infrastructure Change
&lt;/h2&gt;

&lt;p&gt;Migrating orchestration tools, changing a data warehouse, swapping a monitoring platform, or restructuring how a pipeline handles retries are all exactly the kinds of changes that silently break alert wiring. Building a "retest critical alerts" step into the checklist for these kinds of migrations catches breakage before it costs you during a real incident rather than after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Schedule Recurring Verification, Not Just One-Time Testing
&lt;/h2&gt;

&lt;p&gt;A one-time test proves the alert worked on the day you tested it. Systems drift. Schedule a recurring verification, quarterly is reasonable for most teams, for your highest-priority alerts specifically, since those are the ones where silent failure is most costly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools That Make This Easier
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Chaos_engineering" rel="noopener noreferrer"&gt;Chaos engineering&lt;/a&gt; tools and platforms designed for controlled failure injection make Step 2 considerably easier to do safely and repeatably, rather than manually contriving a failure condition each time. &lt;a href="https://www.gremlin.com/" rel="noopener noreferrer"&gt;Gremlin&lt;/a&gt; is one commonly used platform in this space if you want to formalize this kind of testing rather than doing it ad hoc. For teams building on standardized observability, &lt;a href="https://opentelemetry.io/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; based instrumentation makes it easier to confirm exactly which metric or trace triggered a given alert during a test, rather than guessing at causality after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building This Into Your Deployment Pipeline
&lt;/h2&gt;

&lt;p&gt;For teams comfortable with infrastructure-as-code, defining alert rules alongside pipeline definitions and testing them as part of a CI process is a more rigorous version of the manual testing described above. A test suite that deliberately feeds a known-bad input into a staging pipeline and asserts that the expected alert configuration would trigger catches wiring breakage automatically, at merge time, rather than relying on someone remembering to run a manual verification quarterly.&lt;/p&gt;

&lt;p&gt;This is more setup work upfront, and not every team needs to go this far. For smaller teams or a smaller number of critical alerts, manual, scheduled verification gets most of the value without the additional CI infrastructure. The right level of rigor here scales with how costly a silently broken alert would actually be for your specific pipelines.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Doesn't Replace
&lt;/h2&gt;

&lt;p&gt;Testing whether alerts fire correctly doesn't replace a genuine severity model or symptom-based alert design, it's a complementary practice that confirms the alerting system you've designed actually works as intended in practice, not just on paper. &lt;a href="https://137foundry.com/articles/alerting-strategy-data-pipelines-that-doesnt-cry-wolf" rel="noopener noreferrer"&gt;A broader breakdown of designing an alerting strategy that stays trustworthy over time&lt;/a&gt; covers the design side of this problem in more depth, severity tiers, deduplication, and false-positive review, which this kind of testing practice supports rather than substitutes for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We've Seen Go Wrong Without This Practice
&lt;/h2&gt;

&lt;p&gt;The most common failure we've seen isn't a dramatic one, it's a notification channel that got renamed or archived during a routine chat platform cleanup, silently breaking every alert routed to it. Nobody notices until an incident happens and the expected page never arrives. A five minute test, deliberately triggering a low-stakes version of the alert and confirming it actually lands somewhere visible, catches this exact class of problem before it costs you during something that actually matters. It's a small habit against a failure mode that's genuinely easy to prevent and genuinely painful to discover the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making This Sustainable Long Term
&lt;/h2&gt;

&lt;p&gt;The teams that keep this practice going long term tend to attach it to something that already happens on a schedule, a quarterly reliability review, an on-call handoff ritual, rather than treating it as a standalone initiative that has to compete for attention against feature work. Alerting verification that depends on someone remembering to prioritize it eventually stops happening. Alerting verification that's built into an existing recurring ritual tends to survive turnover and shifting priorities much better.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Short Testing Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Inventory every alert your team currently relies on and the specific failure it should catch&lt;/li&gt;
&lt;li&gt;Deliberately trigger each critical alert's condition and confirm it fires, at the right severity, to the right destination&lt;/li&gt;
&lt;li&gt;Measure actual notification latency, not just eventual arrival&lt;/li&gt;
&lt;li&gt;Click through every runbook and dashboard link referenced in the alert&lt;/li&gt;
&lt;li&gt;Retest after any significant infrastructure or orchestration change&lt;/li&gt;
&lt;li&gt;Schedule recurring verification for your highest-priority alerts, not a one-time check&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An alert you've never actually watched fire under controlled conditions is an assumption, not a guarantee. The gap between the two is exactly where incidents go unnoticed. &lt;a href="https://137foundry.com/services/ai-automation" rel="noopener noreferrer"&gt;137Foundry's data automation team&lt;/a&gt; has helped teams build this kind of verification practice alongside a broader alerting overhaul, and it's usually a smaller lift than teams expect once the inventory step is done.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>testing</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why Cause-Based Alerts Break Every Time You Refactor a Pipeline</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Sun, 02 Aug 2026 11:28:03 +0000</pubDate>
      <link>https://dev.to/137foundry/why-cause-based-alerts-break-every-time-you-refactor-a-pipeline-99j</link>
      <guid>https://dev.to/137foundry/why-cause-based-alerts-break-every-time-you-refactor-a-pipeline-99j</guid>
      <description>&lt;p&gt;There's a specific pattern that shows up in almost every data automation codebase that's been around for more than a year or two: alerts tightly coupled to implementation details that quietly stop firing, or start firing incorrectly, the moment someone refactors the code underneath them. Nobody notices until the alert that should have caught an incident stays silent.&lt;/p&gt;

&lt;p&gt;This isn't a rare edge case. It's close to inevitable in any system that changes over time, which is every system worth maintaining. The question isn't whether this will happen to your alerts eventually, it's whether you'll find out from a controlled audit or from a missed incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Cause-Based Alert Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;A cause-based alert triggers on a specific failure mode: "this API call returned a 500," "this specific retry counter hit its max," "this particular exception type was thrown." These feel precise and useful when you write them, because they're tied directly to the bug or incident that prompted the alert in the first place.&lt;/p&gt;

&lt;p&gt;The problem shows up later. The moment someone changes how retries are implemented, swaps the HTTP client library, or restructures error handling to use a different exception hierarchy, the alert's underlying trigger condition no longer matches reality. The alert doesn't throw an error when this happens. It just quietly stops firing, or starts firing on the wrong condition, and nobody finds out until the next incident goes unnoticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is So Easy to Miss
&lt;/h2&gt;

&lt;p&gt;Refactoring code and refactoring alerting logic are almost never done by the same person, at the same time, with the same context. An engineer improving retry logic is reasonably focused on retry behavior, not on auditing every alert rule that happens to reference the old implementation. Alert configuration often lives in a completely separate system, a monitoring platform's UI, a YAML file in a different repo, disconnected from the code review process that would normally catch this kind of drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom-Based Alternative
&lt;/h2&gt;

&lt;p&gt;A symptom-based alert watches an observable outcome instead of an implementation detail: "this table hasn't been updated in the expected window," "this pipeline hasn't completed successfully in X hours," "this data quality check has failed for Y consecutive runs." These conditions describe what actually matters to the business or downstream consumers, and they stay valid regardless of how the underlying code implements retries, error handling, or job orchestration.&lt;/p&gt;

&lt;p&gt;Rewrite the retry logic entirely, migrate to a different orchestration tool, swap the underlying database, the symptom-based alert keeps working, because it was never coupled to the implementation in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Concrete Before and After
&lt;/h2&gt;

&lt;p&gt;Before: "Alert if the vendor API call returns a non-200 status code more than 3 times in a row." This breaks the moment someone adds a circuit breaker that handles those failures gracefully without surfacing the raw status code the same way, or switches HTTP libraries with different error semantics.&lt;/p&gt;

&lt;p&gt;After: "Alert if the vendor data table hasn't received a new batch in the expected daily window." This survives any change to how the ingestion job actually calls the API internally, because it's watching the outcome that matters, not the mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Breakage Is So Hard to Catch in Code Review
&lt;/h2&gt;

&lt;p&gt;Alert configuration usually lives outside the codebase entirely, in a monitoring platform's UI or a separate YAML config repo that isn't part of the normal pull request flow for the pipeline itself. That separation means a reviewer looking at a retry logic refactor has no natural prompt to go check whether any alert rules reference the code being changed. The two systems drift independently, and nothing forces them back into sync until an incident exposes the gap.&lt;/p&gt;

&lt;p&gt;Some teams address this by co-locating alert definitions with the pipeline code they monitor, as infrastructure-as-code, so a pull request that changes error handling at least has a fighting chance of triggering a reviewer to notice a nearby alert rule that references the same logic. It's not a complete fix, but it closes some of the distance between the two systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  This Isn't a Universal Rule
&lt;/h2&gt;

&lt;p&gt;Cause-based alerts aren't inherently wrong. They're genuinely useful for catching specific, well-understood failure modes early, before they cascade into a symptom that takes longer to detect. The mistake is relying on cause-based alerts as your only safety net for a given pipeline. Pairing a small number of targeted cause-based alerts with a broader symptom-based alert as the backstop catches both the specific, known failure modes and the ones nobody anticipated.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Audit Existing Alerts for This Pattern
&lt;/h2&gt;

&lt;p&gt;Go through your current alert rules and ask, for each one, "does this reference a specific implementation detail, a function name, an exception type, a retry count, or does it reference an observable outcome." Anything in the first category is a candidate for a refactor-fragility audit: has the underlying code changed since this alert was written, and if so, does anyone know whether it still fires correctly?&lt;/p&gt;

&lt;p&gt;This audit is tedious the first time and genuinely worth doing, especially for alerts nobody's seen fire in months. A silent, broken alert is worse than no alert at all, because the team believes they're covered when they aren't, a version of the &lt;a href="https://en.wikipedia.org/wiki/Alarm_fatigue" rel="noopener noreferrer"&gt;alarm fatigue&lt;/a&gt; problem where the failure mode is false confidence instead of noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Fits Into a Broader Alerting Strategy
&lt;/h2&gt;

&lt;p&gt;Cause-versus-symptom design is one piece of a larger alerting strategy that also needs severity tiers, deduplication, and a regular false-positive review to stay trustworthy over time. &lt;a href="https://137foundry.com/articles/alerting-strategy-data-pipelines-that-doesnt-cry-wolf" rel="noopener noreferrer"&gt;A fuller breakdown of designing pipeline alerts that don't erode trust&lt;/a&gt; covers the rest of that strategy if you're rebuilding alerting from the ground up rather than patching individual rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tools That Make Symptom-Based Alerting Easier
&lt;/h2&gt;

&lt;p&gt;Standardized observability tooling, like &lt;a href="https://opentelemetry.io/" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; for instrumentation and platforms like &lt;a href="https://grafana.com/" rel="noopener noreferrer"&gt;Grafana&lt;/a&gt; for building dashboards and alert rules against consistent metrics, makes it substantially easier to define symptom-based alerts that don't need to know about implementation details in the first place, since they're built on top of standardized, business-level metrics rather than raw application logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Long This Usually Takes to Surface
&lt;/h2&gt;

&lt;p&gt;In our experience, a broken cause-based alert typically goes unnoticed for weeks to months after the triggering refactor, since the whole point of the failure mode is that nothing errors out, the alert just quietly stops matching reality. The gap usually closes one of two ways: either a symptom-based backstop alert catches the underlying problem and someone traces back to discover the specific alert had gone silent, or a genuine incident happens with no alert at all, and the post-incident review is what finally surfaces the drift. The first path is obviously preferable, which is exactly the argument for pairing cause-based alerts with a symptom-based backstop rather than relying on either alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why New Team Members Make This Worse, Not Better
&lt;/h2&gt;

&lt;p&gt;It's tempting to think a fresh set of eyes on a codebase would catch stale, cause-based alerts during onboarding, but the opposite tends to be true. New team members generally trust that existing alert configuration reflects current reality, since there's no obvious reason to suspect otherwise, and auditing every alert rule against the current codebase isn't a typical onboarding task. The alerts that are most likely to have quietly broken are exactly the ones a new hire is least equipped to question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Practical Takeaway
&lt;/h2&gt;

&lt;p&gt;If an alert rule references something an engineer would only know by reading the current implementation, a specific function, exception type, or retry mechanism, it's fragile by design and will eventually go silent without anyone noticing. Building around observable, business-relevant outcomes instead costs a little more thought upfront and saves a lot of quiet, undetected alerting decay down the line. &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry&lt;/a&gt; has run into this exact pattern rebuilding alerting for data teams more than once, and it's almost always an easy fix once it's actually identified.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why Session Replay Catches UX Bugs That Funnel Analytics Never Will</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Sat, 01 Aug 2026 11:25:45 +0000</pubDate>
      <link>https://dev.to/137foundry/why-session-replay-catches-ux-bugs-that-funnel-analytics-never-will-6cp</link>
      <guid>https://dev.to/137foundry/why-session-replay-catches-ux-bugs-that-funnel-analytics-never-will-6cp</guid>
      <description>&lt;p&gt;Funnel analytics tell you where users drop off. They almost never tell you why. A step with a 35% abandonment rate could be failing because of a confusing label, a validation error that won't clear, a button hidden below the fold on smaller screens, or a dozen other reasons, and a drop-off percentage looks identical for all of them.&lt;/p&gt;

&lt;p&gt;Session replay closes that gap. Watching even a handful of real recordings of users hitting a problem step usually surfaces the actual cause in minutes, something that funnel data alone can take weeks of guessing and A/B testing to isolate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Analytics Dashboards Can't Show You
&lt;/h2&gt;

&lt;p&gt;A conversion funnel is a summary. It compresses thousands of individual user journeys into a handful of percentages, and in doing so it throws away exactly the detail you need to fix the problem: the specific moment of hesitation, the field a user clicked into and out of three times, the rage-click on a button that looked interactive but wasn't.&lt;/p&gt;

&lt;p&gt;Aggregate metrics are great for knowing &lt;em&gt;that&lt;/em&gt; something is wrong. They're bad at telling you &lt;em&gt;what&lt;/em&gt;, and teams that only look at dashboards often end up guessing at fixes based on hunches rather than evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Watching Real Sessions Actually Reveals
&lt;/h2&gt;

&lt;p&gt;A handful of patterns show up constantly once you start watching real user sessions on a problem step:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rage clicks.&lt;/strong&gt; Users clicking the same spot repeatedly, usually because something looked clickable but wasn't, or because a click registered with no visible feedback and they assumed it failed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dead clicks.&lt;/strong&gt; A user clicks somewhere expecting an action, nothing happens, and they never try again, they just leave. These are often the highest-value bugs to fix because they represent a broken expectation, not a design preference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Field re-entry loops.&lt;/strong&gt; A user fills a field, moves on, comes back to it, edits it again, moves on, comes back a third time. This almost always signals a validation message that isn't clear enough, or a format requirement (like a phone number needing dashes or not) that isn't communicated up front.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scroll hesitation near a call to action.&lt;/strong&gt; Users scrolling up and down repeatedly near a button, without clicking, often signals uncertainty about whether that's actually the right next step, frequently a copy problem rather than a layout problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy Has to Come First
&lt;/h2&gt;

&lt;p&gt;Session replay tools capture real user input, which means privacy and compliance considerations aren't optional. Sensitive fields, passwords, payment details, government ID numbers, need to be masked at the recording layer, not redacted after the fact. Most reputable session replay platforms, including established tools like &lt;a href="https://www.hotjar.com/" rel="noopener noreferrer"&gt;Hotjar&lt;/a&gt; and &lt;a href="https://www.fullstory.com/" rel="noopener noreferrer"&gt;FullStory&lt;/a&gt;, support field-level masking by default or by CSS selector. It's worth auditing exactly what gets recorded before rolling the tool out broadly, and cross-checking your configuration against &lt;a href="https://gdpr.eu/" rel="noopener noreferrer"&gt;GDPR.eu's&lt;/a&gt; general guidance on user data handling if you have any users in the EU, rather than discovering a gap after a compliance review flags it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pairing Session Replay With Funnel Data, Not Replacing It
&lt;/h2&gt;

&lt;p&gt;Session replay isn't a replacement for quantitative analytics, it's a complement. Use funnel data to find &lt;em&gt;where&lt;/em&gt; the problem is: which step, which segment of users, which device type. Use session replay to understand &lt;em&gt;why&lt;/em&gt;, by watching a representative sample of the sessions that hit that specific point. Skipping the funnel step and just browsing random recordings wastes time on sessions that may not represent the actual problem you're trying to solve.&lt;/p&gt;

&lt;p&gt;A reasonable workflow: identify the worst-performing step from funnel data, filter session recordings to just that step and that user segment, watch ten to fifteen sessions, and look for the pattern that repeats. It rarely takes more than that to find the actual cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Often You Actually Need to Watch New Recordings
&lt;/h2&gt;

&lt;p&gt;There's a point of diminishing returns worth knowing about. Once a team has watched enough sessions to identify and fix the two or three biggest friction points on a given flow, the marginal value of watching additional recordings on that same flow drops sharply, at least until the next redesign or feature change introduces new friction to find. Treat session replay review as tied to specific investigation questions, not an open-ended stream to monitor constantly, or it becomes a time sink with diminishing insight per hour spent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Connects to Wizard and Multi-Step Form Design
&lt;/h2&gt;

&lt;p&gt;Multi-step forms are one of the highest-value places to apply this, because a single confusing field on step two of a five-step wizard can quietly tank completion for the entire flow, and funnel data alone will only tell you that step two is a problem, not which field on it. If you're designing or auditing a multi-step form, &lt;a href="https://137foundry.com/articles/multi-step-form-wizard-that-doesnt-lose-users" rel="noopener noreferrer"&gt;this breakdown of what makes wizard flows fail&lt;/a&gt; covers the structural failure points, progress indicators, validation timing, state persistence, that session replay is often what reveals in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Habit of Weekly Review
&lt;/h2&gt;

&lt;p&gt;A session replay tool is only useful if someone actually watches the recordings. Teams that buy a tool, set it up once, and never build a recurring habit of reviewing sessions get almost none of the value, the recordings just accumulate unwatched. A better pattern: assign a rotating weekly slot, even just 30 minutes, where someone on the product or design team watches a sample of sessions from the worst-performing step in the current funnel and writes up whatever pattern they noticed. Over a few months, this consistently surfaces more actionable findings than a one-time deep audit, because it catches regressions as they happen rather than months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How This Differs From A/B Testing
&lt;/h2&gt;

&lt;p&gt;It's worth being clear about what session replay is and isn't good for. A/B testing tells you which of two variants performs better in aggregate, but it doesn't tell you why, and it requires enough traffic to reach statistical significance, which many B2B or lower-traffic flows never realistically achieve. Session replay works at any traffic volume, even a handful of recordings on a low-traffic enterprise signup flow can reveal a clear, fixable problem. The two techniques complement each other well: use session replay to generate a hypothesis about what's broken, then, if you have the traffic to support it, validate the fix with an A/B test rather than assuming the fix worked based on gut feel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Objections and Why They Don't Hold Up
&lt;/h2&gt;

&lt;p&gt;Some teams resist adding session replay because of setup overhead or concerns about performance impact. In practice, most modern session replay tools add minimal page weight and load asynchronously, so the performance concern rarely holds up under actual measurement rather than assumption. The privacy concern is real and worth taking seriously, but it's a solvable configuration problem, field masking and sampling rate limits, not a reason to skip the tool entirely. Teams that skip session replay over these concerns usually end up debugging the exact same UX problems through slower, less direct methods: support tickets, user interviews, or pure guesswork.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started Without Overbuilding the Setup
&lt;/h2&gt;

&lt;p&gt;You don't need a fully instrumented analytics stack to start. Recording sessions on your two or three highest-traffic or highest-friction pages, reviewing a sample weekly, and fixing the most obvious repeated pattern is a better use of a team's time than building an elaborate dashboard nobody looks at. Once the obvious issues are fixed, expand coverage and get more selective about what you review.&lt;/p&gt;

&lt;p&gt;For teams building or auditing complex product flows, this kind of evidence-based debugging is exactly the sort of work &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry's web team&lt;/a&gt; does alongside frontend development, pairing what the data shows with what actually gets built. Explore more on how we approach product engineering at &lt;a href="https://137foundry.com/services" rel="noopener noreferrer"&gt;137Foundry&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How to Implement Autosave for Web Forms Without Hammering Your API</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Sat, 01 Aug 2026 11:25:43 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-implement-autosave-for-web-forms-without-hammering-your-api-49pc</link>
      <guid>https://dev.to/137foundry/how-to-implement-autosave-for-web-forms-without-hammering-your-api-49pc</guid>
      <description>&lt;p&gt;We get asked to add autosave to existing forms more often than almost any other single feature request, usually after a client has already lost a customer complaint or two about lost form progress. The frustrating part is that autosave has a reputation for being complicated to build well, when in reality most of the complexity comes from a handful of specific edge cases that are entirely predictable once you know to look for them.&lt;/p&gt;

&lt;p&gt;Autosave sounds simple until you build it: save on every keystroke and you'll flood your backend with requests and probably introduce race conditions where an older save overwrites a newer one. Save too infrequently and you're back to the exact problem autosave was supposed to solve, users losing work. Here's a practical approach that avoids both failure modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Debounce, Don't Save on Every Keystroke
&lt;/h2&gt;

&lt;p&gt;Debouncing delays a save until the user has paused typing for a set interval, typically 500ms to 1500ms, rather than firing a request on every character. This alone eliminates the vast majority of unnecessary network calls. A user typing a paragraph into a textarea generates dozens of keystrokes but, with debouncing, only one or two save requests once they pause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Save on Field Blur, Not Just on a Timer
&lt;/h2&gt;

&lt;p&gt;In addition to debounced saves during typing, trigger a save whenever a field loses focus, the user tabs or clicks to the next field. This catches the case where someone fills a field and immediately moves on before the debounce timer would have fired, which is common and would otherwise leave that field's changes unsaved for longer than necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Handle Out-of-Order Responses
&lt;/h2&gt;

&lt;p&gt;If a user types quickly across multiple fields, save requests can complete out of order, an earlier request finishing after a later one due to network variance. Tag each save request with a monotonically increasing sequence number or timestamp, and on the client, discard any response that's older than the last one you've already applied. Without this, a slow early request can silently overwrite a user's more recent input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Distinguish Local Draft State From Server-Confirmed State
&lt;/h2&gt;

&lt;p&gt;Save to local storage immediately and synchronously, on every change, since that's essentially free and gives you an instant safety net even if the network request fails entirely. Treat the backend save as a separate, asynchronous layer on top of that. This two-tier approach means a user who loses connectivity mid-form doesn't lose anything, local storage has them covered until the connection returns and the backend sync catches up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Show Save State Honestly
&lt;/h2&gt;

&lt;p&gt;A small, unobtrusive "Saved" or "Saving..." indicator does more for user trust than almost any other UI element in an autosave flow. If a save fails, say so, "Couldn't save, retrying" rather than silently failing and leaving the user to assume everything is fine. Silent failures are how autosave systems lose the exact trust they're supposed to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Set a Reasonable Retry and Backoff Strategy
&lt;/h2&gt;

&lt;p&gt;Network requests fail. A save request that fails once shouldn't just vanish, retry with &lt;a href="https://en.wikipedia.org/wiki/Exponential_backoff" rel="noopener noreferrer"&gt;exponential backoff&lt;/a&gt; (wait 1 second, then 2, then 4, capping at a reasonable maximum) rather than hammering the server repeatedly or giving up after one attempt. If retries are exhausted, that's the moment to surface a visible warning to the user rather than failing silently in the background.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local Storage Has Limits Worth Knowing
&lt;/h2&gt;

&lt;p&gt;Browser local storage isn't infinite, and it's synchronous, which means large amounts of data written frequently can briefly block the main thread. The &lt;a href="https://developer.mozilla.org/" rel="noopener noreferrer"&gt;MDN Web Storage API reference&lt;/a&gt; covers the practical size limits (typically 5 to 10MB per origin depending on browser) and the tradeoffs against IndexedDB for larger or more frequent writes. For most form autosave use cases local storage is more than sufficient, but it's worth knowing where the ceiling is before you hit it in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens When the Draft and the Final Submission Diverge
&lt;/h2&gt;

&lt;p&gt;One detail teams often skip: what happens to the draft record once the form is finally submitted successfully. If the draft and the final submission live in separate tables or separate states, make sure the draft is explicitly cleared or marked complete on successful submission, otherwise you end up with orphaned draft records accumulating indefinitely, and worse, a confusing situation where a user who submits successfully but then somehow returns to the form sees stale draft data reappear instead of a fresh start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backend Considerations Worth Planning For
&lt;/h2&gt;

&lt;p&gt;On the server side, autosave endpoints see far more traffic than a typical form submission endpoint, since they're hit repeatedly throughout a session rather than once at the end. Rate limiting per user session, and storing drafts in a lightweight, fast-write data store rather than the same heavily-indexed table used for finalized records, both help keep autosave from becoming a performance bottleneck as usage scales. The &lt;a href="https://web.dev/" rel="noopener noreferrer"&gt;web.dev&lt;/a&gt; guidance on network resilience patterns covers debouncing and retry strategies in more general terms if you want a deeper reference beyond forms specifically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Autosave Matters Most
&lt;/h2&gt;

&lt;p&gt;Autosave has the highest payoff in exactly the scenario covered in our piece on &lt;a href="https://137foundry.com/articles/multi-step-form-wizard-that-doesnt-lose-users" rel="noopener noreferrer"&gt;multi-step form wizards that don't lose users halfway through&lt;/a&gt;: long, multi-step flows where a user might reasonably need to pause and return later. A single-field contact form barely benefits from autosave. A five-step application or onboarding flow benefits enormously, because the cost of losing progress scales directly with how much time a user has already invested.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens When Two Tabs Are Open at Once
&lt;/h2&gt;

&lt;p&gt;A user opening the same form in two browser tabs, deliberately or by accident, creates a genuine edge case that most autosave implementations never account for until it causes a support ticket. Without any coordination, both tabs will happily save over each other, with whichever tab saves last silently winning, potentially discarding real user input from the other tab.&lt;/p&gt;

&lt;p&gt;A practical mitigation: use the browser's &lt;code&gt;BroadcastChannel&lt;/code&gt; API or a shared local storage event listener to detect when a second tab opens the same form, and either warn the user directly ("This form is also open in another tab") or, for simpler cases, just make sure the local storage draft state is shared across tabs so both stay in sync automatically rather than diverging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing an Appropriate Debounce Interval Per Field Type
&lt;/h2&gt;

&lt;p&gt;Not every field benefits from the same debounce timing. A short text field like a name might reasonably debounce at 500ms, since users type it quickly and pause naturally. A long textarea, like a cover letter or project description, benefits from a longer debounce, closer to 1500 or 2000ms, since users pause mid-thought constantly while composing longer text, and saving on every pause would generate far more requests than necessary. Tuning debounce intervals per field type, rather than using one blanket value across the whole form, meaningfully reduces unnecessary backend load on forms with a mix of short and long fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing It Properly Before Launch
&lt;/h2&gt;

&lt;p&gt;Before shipping autosave, deliberately test the edge cases: what happens on a flaky connection, what happens if a user opens the same form in two tabs, what happens if local storage is full or disabled. These aren't hypothetical, they happen in production at meaningful volume, and an autosave system that only works under ideal network conditions isn't really solving the problem it was built for.&lt;/p&gt;

&lt;p&gt;If you're planning a complex form or wizard flow and want help architecting the autosave and state persistence layer correctly the first time, &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry&lt;/a&gt; builds exactly this kind of infrastructure as part of our web development work. More on our approach at &lt;a href="https://137foundry.com/services" rel="noopener noreferrer"&gt;137foundry.com/services&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why a Single Poison Pill Message Can Quietly Stall an Entire Background Job Queue</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Wed, 29 Jul 2026 11:30:57 +0000</pubDate>
      <link>https://dev.to/137foundry/why-a-single-poison-pill-message-can-quietly-stall-an-entire-background-job-queue-227k</link>
      <guid>https://dev.to/137foundry/why-a-single-poison-pill-message-can-quietly-stall-an-entire-background-job-queue-227k</guid>
      <description>&lt;p&gt;A poison pill is a job that can never succeed no matter how many times it's retried, a malformed payload, a reference to data that no longer exists, a bug that reliably crashes the worker processing it. On its own that sounds like a minor annoyance. In queues with certain ordering or concurrency configurations, a single poison pill can stall processing for every job behind it, which is a much bigger problem than one bad job failing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a Single Bad Job Blocks Everything Else
&lt;/h2&gt;

&lt;p&gt;The mechanism depends on your queue's configuration, but the core issue is the same: something enforces ordering or exclusivity in a way that makes forward progress depend on this specific job resolving, and it never will. In a strictly ordered queue where jobs must process in sequence, a poison pill at the front blocks every job queued behind it indefinitely, since the queue won't advance past a job that keeps failing and retrying.&lt;/p&gt;

&lt;p&gt;In a queue partitioned by key, all jobs for a given user or resource routed to the same worker or shard for ordering guarantees, a poison pill tied to that key can stall every other job sharing that partition, even though jobs in other partitions continue processing normally. This is a subtler and often harder to diagnose version of the same problem, since the system as a whole looks healthy while one specific partition is silently stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is Hard to Notice Quickly
&lt;/h2&gt;

&lt;p&gt;Aggregate queue metrics, total throughput, overall error rate, often look basically normal while this is happening, since only one ordered lane or one partition is actually stuck. A dashboard showing healthy overall throughput can mask a specific customer or resource whose jobs haven't processed in hours, and the first sign of trouble is often a support ticket rather than a monitoring alert, because nothing in the aggregate view crossed an alerting threshold.&lt;/p&gt;

&lt;p&gt;This is exactly why per-partition or per-key visibility matters as much as aggregate queue health for any system using ordered or sharded processing. A queue depth chart that only shows the total across all partitions combined will hide a single stuck partition behind healthy throughput everywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Isolate and Skip, Don't Block Forever
&lt;/h2&gt;

&lt;p&gt;The standard fix is capping retries per job and, once that cap is hit, moving the job out of the blocking path entirely, to a dead letter queue, rather than leaving it in place to keep blocking everything behind it. For ordered queues specifically, this usually means the ordering guarantee needs an explicit "skip and log" mechanism for jobs that exceed their retry budget, since blind adherence to strict ordering with no escape hatch is exactly what turns one poison pill into a systemic outage.&lt;/p&gt;

&lt;p&gt;For partitioned queues, the same principle applies per partition: a job that's failed its retry budget within one partition should be removed from that partition's processing lane rather than left in place blocking every subsequent job routed to the same shard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing for This Before It Happens
&lt;/h2&gt;

&lt;p&gt;The cheapest time to solve this problem is during initial design, not during an incident. Decide up front what happens when a job exceeds its retry budget in an ordered or partitioned context: does it get skipped with an alert, moved to a dead letter queue with the ordering guarantee intentionally broken for that one job, or does the system genuinely require strict ordering with no exceptions, in which case you need a clear escalation path for manual intervention when a poison pill does show up.&lt;/p&gt;

&lt;p&gt;Retrofitting this decision during a live incident, with a partition already stuck and jobs backing up, is a much worse time to be designing the escape hatch than during a calm architecture review before the system has ever needed one.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Realistic Example
&lt;/h2&gt;

&lt;p&gt;Imagine an e-commerce system processing order fulfillment jobs, partitioned by customer ID so that a given customer's orders always process in the sequence they were placed. One customer's order references a shipping address that was deleted from the database in a data cleanup script, and the fulfillment job crashes every time it tries to look up that address.&lt;/p&gt;

&lt;p&gt;Without an escape hatch, every subsequent order from that customer sits queued behind the failing job indefinitely, invisible in aggregate metrics since it's one partition out of thousands. With a retry cap and a dead-letter path, that one order gets flagged for manual review after its third failed attempt, and the rest of that customer's orders continue processing normally behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Actually Detect a Stuck Partition
&lt;/h2&gt;

&lt;p&gt;Since aggregate metrics hide this problem so effectively, detecting it requires tracking something more granular: the age of the oldest unprocessed job within each partition or ordered lane, not just the total count of pending jobs system-wide. An alert on "oldest pending job in any single partition exceeds five minutes," rather than "total queue depth exceeds some threshold," catches exactly the failure mode described above while a purely aggregate alert would stay silent.&lt;/p&gt;

&lt;p&gt;This kind of per-partition monitoring costs more to build than a simple aggregate dashboard, since it requires tracking state per partition rather than one global number, but it's the difference between finding out about a stuck customer from an internal alert versus finding out from that customer's support ticket days later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tradeoff Between Strict Ordering and Resilience
&lt;/h2&gt;

&lt;p&gt;It's worth being honest that strict ordering and resilience to poison pills are in some tension with each other. The stricter the ordering guarantee, every job for this key processes in exact sequence no matter what, the more damage a single bad job can do if there's no escape hatch. Relaxing ordering slightly, allowing a small amount of reordering in exchange for the ability to skip a permanently failing job, trades a small amount of strictness for a large amount of resilience.&lt;/p&gt;

&lt;p&gt;Not every system can make that tradeoff. Some genuinely require strict ordering for correctness reasons, financial ledger entries being a common example. For those systems, the escape hatch has to be a manual intervention path rather than an automatic skip, but it still needs to exist, since "manual intervention is always available" only helps if someone actually notices the stuck partition in time to intervene.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://kafka.apache.org" rel="noopener noreferrer"&gt;Apache Kafka's documentation&lt;/a&gt; covers partitioned, ordered message processing and the tradeoffs involved in maintaining order guarantees at scale. &lt;a href="https://aws.amazon.com" rel="noopener noreferrer"&gt;AWS SQS&lt;/a&gt; documents FIFO queue behavior and its own dead letter queue mechanics for exactly this scenario. Wikipedia's entry on &lt;a href="https://en.wikipedia.org/wiki/Message_queue" rel="noopener noreferrer"&gt;message queues&lt;/a&gt; covers ordering guarantees as a general concept across different queue implementations.&lt;/p&gt;

&lt;p&gt;The full architecture for a durable job queue, including retry budgets and dead-letter design that prevent this exact failure mode, is covered in &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry&lt;/a&gt;'s guide, &lt;a href="https://137foundry.com/articles/background-job-queue-survives-server-restart" rel="noopener noreferrer"&gt;How to Build a Background Job Queue That Survives a Server Restart&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Strict ordering and partition-based routing both solve real problems. Neither should be adopted without also deciding, in advance, what happens the day one job in that ordered lane simply refuses to ever succeed, since that day tends to arrive eventually in any system running long enough at real volume, usually at the least convenient possible moment.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Job Queue vs Message Queue vs Task Scheduler: The Difference That Actually Matters</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Wed, 29 Jul 2026 11:30:56 +0000</pubDate>
      <link>https://dev.to/137foundry/job-queue-vs-message-queue-vs-task-scheduler-the-difference-that-actually-matters-4lp5</link>
      <guid>https://dev.to/137foundry/job-queue-vs-message-queue-vs-task-scheduler-the-difference-that-actually-matters-4lp5</guid>
      <description>&lt;p&gt;These three terms get used almost interchangeably in casual conversation, and for a lot of small systems that's harmless. Once a system grows past a handful of background tasks, picking the wrong one of the three, or bolting features from one onto a tool built for another, starts causing real friction. Here's what actually distinguishes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job Queues: Work Items With a Lifecycle
&lt;/h2&gt;

&lt;p&gt;A job queue is built around discrete units of work that move through a defined lifecycle: queued, in progress, succeeded or failed, sometimes retried, sometimes dead-lettered. Job queues typically track state per job, support retries with backoff, and expose visibility into what's pending, what's running, and what's failed.&lt;/p&gt;

&lt;p&gt;Tools in this category, Sidekiq, Celery, BullMQ, are built specifically around this lifecycle. If your background work needs retry logic, failure visibility, and per-job state tracking, a job queue is almost certainly the right category, even if you end up building the specific implementation yourself rather than adopting one of the named tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Message Queues: Delivery, Not Lifecycle
&lt;/h2&gt;

&lt;p&gt;A message queue's job is getting a message from a producer to one or more consumers reliably, and that's a narrower scope than a job queue's full lifecycle tracking. RabbitMQ and traditional message brokers excel at routing, fan-out to multiple consumers, and delivery guarantees, but they don't inherently track "this specific unit of work is 40% retried and here's its failure history" the way a job queue framework does out of the box.&lt;/p&gt;

&lt;p&gt;Message queues are often the transport layer underneath a job queue system rather than a replacement for one. A job queue framework might use a message queue internally to move work between producer and worker, while adding the lifecycle tracking, retry logic, and dead lettering on top as its own layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task Schedulers: Time, Not Events
&lt;/h2&gt;

&lt;p&gt;A task scheduler's defining feature is time-based triggering: run this job at 2am, run this job every 15 minutes, run this job on the first of the month. Cron is the simplest example, and more sophisticated schedulers add distributed coordination so a scheduled task doesn't fire multiple times across redundant instances of the same service.&lt;/p&gt;

&lt;p&gt;Task schedulers generally don't care about the reactive, event-driven triggering that job queues and message queues are built around, a job that fires the instant a user takes an action rather than at a predetermined time. Some systems genuinely need both: a scheduler to kick off a recurring task, which then enqueues work onto a job queue for the actual processing and retry handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Teams Get This Wrong
&lt;/h2&gt;

&lt;p&gt;The most common mistake is trying to bolt job-queue features onto a plain message queue without acknowledging the extra work involved: adding retry counters, failure state, and dead lettering yourself on top of a tool that wasn't designed around tracking that lifecycle in the first place. It's not impossible, but it's meaningfully more work than reaching for a tool built around the lifecycle from the start.&lt;/p&gt;

&lt;p&gt;The second common mistake is using a task scheduler for reactive work by polling on a tight interval instead of switching to an event-driven job queue, which wastes resources checking for work that isn't there most of the time and adds latency equal to your polling interval for work that is there.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Way to Decide
&lt;/h2&gt;

&lt;p&gt;Ask what's actually driving the work: a specific event a user or system triggered, or a predetermined schedule. Event-driven work almost always wants a job queue. Time-driven work wants a scheduler, possibly one that hands off to a job queue for the actual processing once triggered.&lt;/p&gt;

&lt;p&gt;Then ask how much you care about per-job state: retries, failure visibility, dead lettering. If the answer is "a lot," a dedicated job queue framework saves you from rebuilding that lifecycle tracking from scratch on top of a lower-level message queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Concrete Example Walking Through All Three
&lt;/h2&gt;

&lt;p&gt;Say you're building a system that sends a weekly digest email, processes uploaded files immediately when a user submits one, and needs to reliably deliver webhook payloads to a third-party integration. The weekly digest is purely time-driven, no external event triggers it, so a task scheduler firing once a week is the right layer, and it can hand off the actual email-sending work to a job queue so a transient email provider outage doesn't lose the digest entirely.&lt;/p&gt;

&lt;p&gt;The file processing is event-driven and needs full lifecycle tracking, since a corrupted upload or a processing bug needs to retry with backoff and eventually land in a dead letter queue for manual review rather than silently failing. That's squarely a job queue's job. The webhook delivery needs reliable point-to-point or fan-out delivery with acknowledgment, which is closer to a message queue's core strength, though many teams end up wrapping it in job-queue-style retry logic anyway once delivery failures start happening in production.&lt;/p&gt;

&lt;p&gt;Notice that none of these three needs live in isolation. A real system usually ends up with a scheduler triggering jobs, jobs using a message queue as their underlying transport, and a job queue framework's retry and dead-letter logic wrapped around all of it. The terminology confusion often comes from correctly noticing all three layers are present and incorrectly assuming that means the distinction between them doesn't matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  When It's Fine to Just Use One Tool for Everything
&lt;/h2&gt;

&lt;p&gt;None of this is an argument for always running three separate systems. A small application with modest background work volume can reasonably run everything through a single job queue framework, using its scheduling plugin for time-based work and accepting that its message transport isn't as sophisticated as a dedicated broker, because the operational simplicity of one system is worth more than the theoretical purity of three specialized ones at that scale.&lt;/p&gt;

&lt;p&gt;The distinction matters most once volume or complexity grows enough that a single tool's limitations start showing up as real operational pain: a scheduler that can't coordinate across multiple instances without double-firing, a job queue straining under message routing patterns it wasn't built for, or a message broker that's had ad hoc retry logic bolted onto it in three different places by three different engineers who didn't know about each other's work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Terminology Confusion Persists
&lt;/h2&gt;

&lt;p&gt;Part of why these terms blur together in practice is that a mature job queue system is usually built on top of a message queue, and a scheduler frequently hands its triggered work off to a job queue for actual execution. Looking at a production system from the outside, you'll often see all three layers working together, which makes it easy to describe the whole stack loosely as "the queue" without distinguishing which layer is actually doing what.&lt;/p&gt;

&lt;p&gt;That's fine for casual conversation. It stops being fine the moment you're deciding what to build or adopt for a new piece of background work, since picking the wrong category means either missing features you'll end up building yourself anyway, or paying for complexity your actual use case doesn't need.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://docs.celeryq.dev" rel="noopener noreferrer"&gt;Celery's documentation&lt;/a&gt; covers job queue concepts in depth for the Python ecosystem. &lt;a href="https://www.rabbitmq.com" rel="noopener noreferrer"&gt;RabbitMQ&lt;/a&gt; is a widely used message broker whose own documentation is a good reference for message queue fundamentals distinct from job lifecycle tracking. Wikipedia's entry on &lt;a href="https://en.wikipedia.org/wiki/Message_queue" rel="noopener noreferrer"&gt;message queues&lt;/a&gt; covers the general pattern these tools all build on in different ways.&lt;/p&gt;

&lt;p&gt;For a full walkthrough of building a durable job queue from the ground up, including retry and dead-letter design, see &lt;a href="https://137foundry.com/articles/background-job-queue-survives-server-restart" rel="noopener noreferrer"&gt;this background job queue guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;None of these three categories is objectively better than the others. They solve different problems, and the friction most teams hit comes from picking one and then trying to force it to solve a problem it wasn't designed around instead of reaching for the tool actually built for the job.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why Job Queue Starvation Sneaks In Even After You Add Priority Lanes</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Tue, 28 Jul 2026 11:18:24 +0000</pubDate>
      <link>https://dev.to/137foundry/why-job-queue-starvation-sneaks-in-even-after-you-add-priority-lanes-16ke</link>
      <guid>https://dev.to/137foundry/why-job-queue-starvation-sneaks-in-even-after-you-add-priority-lanes-16ke</guid>
      <description>&lt;p&gt;Adding priority lanes to a job queue feels like solving the "urgent work stuck behind bulk work" problem for good. High-priority jobs get their own lane, workers check it first, done. Then a few months later someone notices that low-priority jobs from three days ago are still sitting there, and the fix that was supposed to prevent exactly this has quietly caused it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that looks correct
&lt;/h2&gt;

&lt;p&gt;The common implementation: workers check the high-priority queue first, and only pull from the low-priority queue if the high-priority one is empty. It's a strict priority order, and it's the first design most teams reach for because it's the simplest to implement and the easiest to explain.&lt;/p&gt;

&lt;p&gt;It also has an obvious failure mode once you write it down: if high-priority work arrives fast enough to keep the high-priority queue non-empty, the low-priority queue never gets touched. Not "gets touched slowly." Never. This is &lt;a href="https://en.wikipedia.org/wiki/Priority_inversion" rel="noopener noreferrer"&gt;priority inversion&lt;/a&gt;'s quieter cousin, sometimes called starvation, and it's a well-documented problem in scheduling theory precisely because strict priority ordering guarantees it under sustained load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to miss in testing
&lt;/h2&gt;

&lt;p&gt;Starvation doesn't show up in normal load testing because most load tests don't sustain a continuous stream of high-priority work for hours. In staging, the high-priority queue drains, the low-priority queue gets its turn, everything looks fine. In production, a marketing campaign or a traffic spike can keep high-priority jobs arriving continuously for an entire afternoon, and that's exactly when the low-priority backlog starts growing invisibly. Nobody notices until someone asks why a report that's supposed to generate within an hour took two days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Weighted fair queuing fixes the guarantee, not just the symptom
&lt;/h2&gt;

&lt;p&gt;The standard fix isn't to abandon priority lanes, it's to stop using strict ordering. A weighted scheme, where the queue processes (for example) four high-priority jobs for every one low-priority job instead of all-high-then-low, guarantees the low-priority lane makes forward progress no matter how busy the high-priority lane gets. &lt;a href="https://redis.io/docs/latest/develop/data-types/" rel="noopener noreferrer"&gt;Redis's own documentation on lists and sorted sets&lt;/a&gt; covers the primitives most teams build this on top of, and most managed queue services (RabbitMQ included, via its &lt;a href="https://www.rabbitmq.com/docs" rel="noopener noreferrer"&gt;priority queue support&lt;/a&gt;) support a weighted or ratio-based consumption pattern if you look past the default strict-priority example in the docs.&lt;/p&gt;

&lt;p&gt;The ratio doesn't need to be perfectly tuned on day one. Even a rough 4:1 split turns "low priority jobs can wait forever" into "low priority jobs take a bounded amount longer," which is a completely different operational conversation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Priority lanes without a starvation guarantee aren't really priority lanes, they're just a queue that occasionally ignores half its own backlog." - Dennis Traina, &lt;a href="https://137foundry.com/services" rel="noopener noreferrer"&gt;founder of 137Foundry&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Aging is the other half of the fix
&lt;/h2&gt;

&lt;p&gt;Weighted consumption handles the common case, but a genuinely pathological burst of high-priority traffic can still starve low-priority work longer than acceptable. Job aging solves this directly: a job's effective priority increases the longer it sits unprocessed, until eventually even a "low priority" job that's been waiting six hours gets treated as urgent regardless of what's arriving behind it. This is more code to maintain than a fixed weight ratio, so most teams add it only after weighted consumption alone proves insufficient under real traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  A rough example of the difference in practice
&lt;/h2&gt;

&lt;p&gt;Picture a queue running at 200 high-priority jobs an hour and 50 low-priority jobs an hour, with strict priority consumption. If the high-priority lane never empties (which is exactly what "200 an hour, arriving continuously" means in practice), the low-priority lane's 50-jobs-an-hour backlog just accumulates, hour after hour, with no upper bound on how long any individual low-priority job waits. Switch to a 4:1 weighted split and the low-priority lane now gets roughly a quarter of total worker capacity, guaranteed, regardless of how busy the high-priority side gets. The backlog stops growing unboundedly and instead settles into whatever steady-state delay the weighting produces, which you can calculate and communicate to whoever's asking why their report is taking twenty minutes instead of two.&lt;/p&gt;

&lt;p&gt;That number, the steady-state delay under a given weight ratio, is worth computing explicitly rather than guessing at. It turns "priority lanes" from a vague reassurance into a specific, testable service-level expectation you can actually hold the system to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring is the only way you'll catch this before someone else does
&lt;/h2&gt;

&lt;p&gt;Starvation is invisible in aggregate throughput metrics, because aggregate throughput looks fine right up until the low-priority backlog is enormous. The metric that actually catches it is age of oldest unprocessed job, broken out by priority lane. If that number for the low-priority lane is climbing steadily instead of oscillating around a stable value, starvation is happening in real time, whether or not anyone's complained yet. This is a cheap metric to add and one of the highest-signal ones for exactly this failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starvation isn't unique to job queues
&lt;/h2&gt;

&lt;p&gt;It's worth naming that this is a general scheduling problem, not something specific to background job systems. Operating system schedulers, network packet queues, and thread schedulers all have well-studied versions of the exact same tradeoff: strict priority is simple and gives the highest-priority work the strongest possible guarantee, at the cost of potentially starving everything below it. Job queues just happen to be the layer where most application engineers first run into it directly, usually without realizing there's decades of prior art on exactly this tradeoff to draw from. If you want the more formal treatment, most operating systems textbooks cover starvation and aging under CPU scheduling, and the concepts map onto job queues almost without modification, just replace "CPU time slice" with "worker slot."&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on multi-tenant queues
&lt;/h2&gt;

&lt;p&gt;The starvation problem gets sharper when a single queue serves multiple tenants or customers rather than just multiple internal priority levels. If tenant A generates ten times the job volume of tenant B, a naive FIFO or strict-priority queue can let tenant A's volume crowd out tenant B's jobs entirely, which is a very different kind of incident (a customer-facing SLA breach) than an internal team noticing a report is late. Per-tenant fairness usually needs its own weighting scheme, separate from whatever priority levels exist within a single tenant's jobs, and it's worth designing for explicitly if your queue serves more than one customer rather than retrofitting it after the first tenant complains.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix doesn't require a rewrite
&lt;/h2&gt;

&lt;p&gt;The reassuring part of all this is that fixing strict-priority starvation rarely means redesigning the queue. In most implementations it's a change to the worker's consumption loop, from "always check the high-priority list first" to "check the high-priority list N times for every one time you check the low-priority list," plus one new metric tracking oldest-job age per lane. That's an afternoon of work for most teams, not a migration. The hard part isn't the fix, it's noticing the problem exists before a customer or a stakeholder notices it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check if you already have priority lanes
&lt;/h2&gt;

&lt;p&gt;If your queue has priority lanes today, it's worth confirming which pattern is actually running, because "we have priority lanes" and "our low-priority work is guaranteed to make progress" are not the same claim. Check whether the consumption logic is strict-first or weighted, and if you don't know, that's usually because it was strict-first by default and nobody revisited it after the initial implementation. The retry and persistence patterns that make a queue durable, covered in &lt;a href="https://137foundry.com/articles/background-job-queue-survives-server-restart" rel="noopener noreferrer"&gt;our guide to building a job queue that survives a server restart&lt;/a&gt;, don't help here at all; starvation is a scheduling problem, not a durability one, and it needs its own fix.&lt;/p&gt;

&lt;p&gt;More on how we design queue architecture that holds up under real production load at &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137foundry.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>5 Free Tools for Monitoring Background Job Queues in Production</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Tue, 28 Jul 2026 11:18:23 +0000</pubDate>
      <link>https://dev.to/137foundry/5-free-tools-for-monitoring-background-job-queues-in-production-4i12</link>
      <guid>https://dev.to/137foundry/5-free-tools-for-monitoring-background-job-queues-in-production-4i12</guid>
      <description>&lt;p&gt;A queue that's silently falling behind looks identical to a healthy one until someone notices the emails are three hours late. Monitoring is what closes that gap, and you don't need an enterprise observability contract to get real visibility into queue depth, worker throughput, and failure rate. Here are five free tools worth setting up before your queue is the thing paging you at 2am.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Prometheus
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://prometheus.io" rel="noopener noreferrer"&gt;Prometheus&lt;/a&gt; is the default choice for metrics collection in most modern infrastructure, and for good reason: it pulls metrics on a schedule, stores them as time series, and lets you query them with PromQL for exactly the kind of question a queue raises constantly, like "what's the 95th percentile job processing time over the last hour." Most queue libraries either expose a Prometheus endpoint natively or have a community exporter that does. Once queue depth, job duration, and failure counts are flowing into Prometheus, everything downstream (dashboards, alerts) gets much easier to build.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Grafana
&lt;/h2&gt;

&lt;p&gt;Metrics without a dashboard are just numbers nobody looks at until something breaks. &lt;a href="https://grafana.com" rel="noopener noreferrer"&gt;Grafana&lt;/a&gt; turns Prometheus data into the kind of dashboard you can glance at during a standup: queue depth over time, per-worker throughput, retry rate by job type. The free tier covers everything a small-to-mid-sized team needs, and prebuilt dashboard templates exist for most popular queue backends, so you're rarely starting from a blank panel.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. OpenTelemetry
&lt;/h2&gt;

&lt;p&gt;Metrics tell you &lt;em&gt;that&lt;/em&gt; something is slow. Traces tell you &lt;em&gt;where&lt;/em&gt;. &lt;a href="https://opentelemetry.io" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; is the vendor-neutral standard for instrumenting distributed systems with traces, and it's particularly useful for job queues because a single job often touches multiple services (the enqueue call, the worker pickup, a downstream API call, a database write). Instrumenting the queue path with OpenTelemetry means you can follow one slow job end to end instead of guessing which hop added the latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Sentry
&lt;/h2&gt;

&lt;p&gt;Job failures that only show up in a log file get ignored. &lt;a href="https://sentry.io" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt; catches unhandled exceptions in worker code and groups them by root cause, so a job that's failing for the same underlying reason a hundred times a day shows up as one issue, not a hundred lines of noise. The free tier is generous enough for most background job workloads, and the alerting integrates with Slack and email without extra setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Flower
&lt;/h2&gt;

&lt;p&gt;If your stack runs on Celery, &lt;a href="https://flower.readthedocs.io" rel="noopener noreferrer"&gt;Flower&lt;/a&gt; is purpose-built for exactly this job. It's a lightweight web UI that shows active workers, task history, queue depth, and lets you inspect (or even revoke) individual in-flight tasks in real time. It's not a full observability platform, but for a quick "what is my queue actually doing right now" view, it's faster to stand up than anything else on this list.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Teams instrument the API and skip the queue every time, and the queue is usually where the actual user-facing delay is hiding." - Dennis Traina, &lt;a href="https://137foundry.com/services" rel="noopener noreferrer"&gt;founder of 137Foundry&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How these five tools fit together, end to end
&lt;/h2&gt;

&lt;p&gt;It helps to think of these as a pipeline rather than five independent options to choose between. Prometheus is the collection layer: it's what actually scrapes and stores the raw numbers, queue depth, job duration, retry count, at a fixed interval, and everything else in this list either feeds it or reads from it. Grafana sits on top as the visualization layer, turning those stored time series into the dashboard someone actually looks at during a standup or an incident. OpenTelemetry operates alongside both, at the trace level rather than the metrics level, answering "where did the time go within this one job" rather than "how is the queue trending over the last hour." Sentry and Flower are more specialized: Sentry catches the unhandled exceptions that never should have made it to a metrics dashboard in the first place, and Flower (if you're on Celery specifically) gives you a live operational view for the "what is happening right now" question that a historical dashboard answers poorly.&lt;/p&gt;

&lt;p&gt;None of the five require you to adopt all of them simultaneously. A reasonable starting point for a team with nothing in place today: get Prometheus and Grafana running first, since that pair alone turns "the queue feels slow" into an actual number you can point at. Add Sentry next if unhandled exceptions in worker code are currently only visible in raw logs. OpenTelemetry and Flower (or your queue's equivalent live-inspection tool) are worth adding once the first two are established and you're debugging specific slow jobs rather than just tracking overall health.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-hosted versus managed versions
&lt;/h2&gt;

&lt;p&gt;All five of these have a free, self-hosted path, which is what makes them worth listing here, but most also offer a managed or cloud-hosted tier if running your own Prometheus and Grafana instances isn't something your team wants to own. Grafana Cloud and hosted Prometheus-compatible services exist specifically for teams that want the dashboards without the operational overhead of running the storage layer themselves. Sentry's hosted tier is, for most teams, the default choice over self-hosting anyway, since self-hosted Sentry is a genuinely heavier piece of infrastructure to maintain than the value it captures for a small queue. Start with whichever version gets you real visibility fastest; you can always migrate between self-hosted and managed later without changing how your jobs are instrumented.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually alert on with these tools
&lt;/h2&gt;

&lt;p&gt;Having the data flowing is only half the job; the other half is deciding what should wake someone up versus what's just useful for a retrospective. Queue depth trending upward for an hour is worth an alert. A single spike that clears in two minutes usually isn't. Retry rate crossing a threshold (say, more than 5% of jobs needing more than one attempt) is a much better early-warning signal than raw failure count, because it catches degradation before jobs actually start failing outright. Most teams get this backwards at first, alerting on absolute counts that scale with traffic instead of rates that stay meaningful as volume grows.&lt;/p&gt;

&lt;p&gt;Worker saturation is the other metric worth dashboarding explicitly: how many workers are currently busy versus idle, sampled continuously. A queue that's growing while workers sit idle points at a completely different problem (a stuck job holding a worker, a crashed consumer that isn't restarting) than a queue that's growing because every worker is maxed out and there simply aren't enough of them. The five tools above will surface both signals if you build the dashboards to separate them; the default dashboard templates that ship with most of these tools usually don't split queue-growth-from-idle-workers out as its own panel, so it's worth adding manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire it up before you need it
&lt;/h2&gt;

&lt;p&gt;None of these tools require a large time investment individually, but the value compounds when they're all pointed at the same queue: Prometheus for the numbers, Grafana for the trend lines, OpenTelemetry for tracing a specific slow job, Sentry for catching the exceptions nobody's watching for, and Flower (or your queue library's equivalent) for a quick real-time check. The alternative is finding out your queue has been backing up for six hours because a customer complained first. We covered the retry and persistence side of building a reliable queue in &lt;a href="https://137foundry.com/articles/background-job-queue-survives-server-restart" rel="noopener noreferrer"&gt;our guide to a background job queue that survives a server restart&lt;/a&gt;; monitoring is the half of the story that tells you when that reliability is actually being tested.&lt;/p&gt;

&lt;p&gt;More on how we think about production-grade backend infrastructure at &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137foundry.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why Queue Depth Alone Is a Bad Signal for Autoscaling Workers</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Mon, 27 Jul 2026 11:18:49 +0000</pubDate>
      <link>https://dev.to/137foundry/why-queue-depth-alone-is-a-bad-signal-for-autoscaling-workers-n2p</link>
      <guid>https://dev.to/137foundry/why-queue-depth-alone-is-a-bad-signal-for-autoscaling-workers-n2p</guid>
      <description>&lt;p&gt;A client's job queue kept spiking to a few thousand pending items every afternoon, and the autoscaler responded exactly as configured: it added workers. Cost went up, the backlog cleared a little faster, and the same spike showed up again the next afternoon like nothing had changed. Queue depth was the wrong number to scale on, and it took us longer than it should have to admit that.&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%2Fimages.unsplash.com%2Fphoto-1775519520461-6b6e068d9250%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5MzI0MTZ8MHwxfHNlYXJjaHw4fHxzZXJ2ZXIlMjByYWNrJTIwY2FibGVzJTIwb3JnYW5pemVkfGVufDF8fHx8MTc4NTE1MTEyN3ww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" 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%2Fimages.unsplash.com%2Fphoto-1775519520461-6b6e068d9250%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5MzI0MTZ8MHwxfHNlYXJjaHw4fHxzZXJ2ZXIlMjByYWNrJTIwY2FibGVzJTIwb3JnYW5pemVkfGVufDF8fHx8MTc4NTE1MTEyN3ww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Server rack with organized cables" width="1080" height="1620"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@ericstoynov?utm_source=137foundry&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Eric Stoynov&lt;/a&gt; on &lt;a href="https://unsplash.com?utm_source=137foundry&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What queue depth actually measures
&lt;/h2&gt;

&lt;p&gt;Queue depth tells you how many jobs are waiting. It says nothing about how long they'll take, how urgent they are, or whether the backlog is growing or shrinking. A thousand jobs that each take fifty milliseconds is a completely different situation from a hundred jobs that each take two minutes, and a raw count treats them identically. Scaling decisions made on that single number end up reacting to the wrong thing more often than they react to the right one.&lt;/p&gt;

&lt;p&gt;In the client's case, the afternoon spike was a batch of low-priority report jobs landing all at once from a scheduled export, not an increase in real user demand. The autoscaler saw a big number and added capacity for work that had no deadline at all, while the actual time-sensitive jobs, a much smaller and steadier stream, never needed the extra workers in the first place.&lt;/p&gt;
&lt;h2&gt;
  
  
  The metric that actually mattered
&lt;/h2&gt;

&lt;p&gt;What we switched to was oldest-job age: how long has the single oldest pending job been waiting, broken out per job type rather than as one blended number across the whole queue. That number answers the question an operator actually cares about, is anything waiting too long, directly, instead of forcing someone to infer it from a count that doesn't distinguish urgent work from bulk work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;oldest_pending_age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;job_type&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;job_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="n"&gt;oldest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enqueued_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;oldest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enqueued_at&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once autoscaling read from this number per job type instead of total queue depth, the afternoon batch stopped triggering scale-up events entirely, because those report jobs had no age threshold they were violating. Nobody was waiting on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this took a while to notice
&lt;/h2&gt;

&lt;p&gt;The reason queue depth is the default metric almost everywhere is that it's the easiest number to expose. Most queue backends, whether that's &lt;a href="https://redis.io" rel="noopener noreferrer"&gt;Redis&lt;/a&gt; lists or a database table, return a count with a single cheap query, while oldest-job age per type requires either an indexed timestamp column or a bit more work against the broker's API. The easy metric became the default metric, and the default metric became the thing dashboards and autoscaling rules were built around before anyone stopped to ask whether it actually correlated with the problem it was meant to catch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Every autoscaling rule is really a bet about what a specific number implies. The number one client's dashboard treated as 'the queue is struggling' turned out to mean nothing more than 'a scheduled job ran,' and nobody had questioned that assumption in over a year." - Dennis Traina, &lt;a href="https://137foundry.com/services" rel="noopener noreferrer"&gt;founder of 137Foundry&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Checking the assumption before trusting the new metric
&lt;/h2&gt;

&lt;p&gt;Before committing to oldest-job age as the replacement signal, we spent a couple of days validating that it actually correlated with the thing we cared about, real user-facing delay, rather than assuming a more sophisticated-sounding metric was automatically better than the simple one. We pulled a sample of incidents from the previous quarter where users had genuinely complained about slowness and checked what oldest-job age looked like at those moments versus what raw queue depth looked like. Age tracked the complaints closely. Depth didn't track them at all, spiking just as often on quiet, complaint-free days as it did during actual incidents.&lt;/p&gt;

&lt;p&gt;That validation step mattered more than it might sound like it should. It's easy to swap one metric for another that sounds more precise without confirming it actually measures the right thing, and a wrong assumption baked into an autoscaling rule is expensive to unwind later, once dashboards and alerting are already built around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed in practice
&lt;/h2&gt;

&lt;p&gt;The fix wasn't a rewrite of the autoscaler, just a change to what it reads. We added a per-job-type oldest-pending-age gauge, set a threshold specific to each job type's actual latency requirement (thirty seconds for payment confirmations, twenty minutes for batch reports), and pointed the scale-up trigger at that instead of the blended count. Total worker count during a normal day barely changed. What changed was that scale-up events started correlating with genuine user-facing delay instead of firing on bulk work nobody was waiting for.&lt;/p&gt;

&lt;p&gt;We also kept a secondary, much looser rule on raw depth as a safety net, because an unbounded queue eventually means something regardless of age, even if every individual job is technically still within its threshold. But it's a backstop now, not the primary signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this connects to the durability side
&lt;/h2&gt;

&lt;p&gt;Autoscaling and job durability are separate problems that tend to surface around the same maturity point in a job system's life. We wrote up the durability half, what actually keeps a job from vanishing if a worker process restarts mid-task, in &lt;a href="https://137foundry.com/articles/background-job-queue-survives-server-restart" rel="noopener noreferrer"&gt;a longer guide on building a background job queue that survives a server restart&lt;/a&gt;. Getting the metrics right doesn't help if the underlying queue can silently lose work; both pieces need to be solid before the system earns any trust from the team that has to operate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we watch for after making this kind of change
&lt;/h2&gt;

&lt;p&gt;Switching the scaling signal isn't a one-time fix you can walk away from. We keep a close eye on two things for a few weeks after: whether the new per-job-type thresholds still hold as traffic patterns shift, since a threshold tuned for today's volume can become wrong within a quarter as a product grows, and whether any job type was left off the per-type breakdown entirely, which quietly falls back to whatever default behavior existed before the change. Cloud providers' own autoscaling documentation, including &lt;a href="https://aws.amazon.com/autoscaling/" rel="noopener noreferrer"&gt;AWS's guidance on Auto Scaling&lt;/a&gt; and &lt;a href="https://cloud.google.com/compute/docs/autoscaler" rel="noopener noreferrer"&gt;Google Cloud's autoscaling documentation&lt;/a&gt;, both make the same point in different words: a scaling policy is only as good as the signal it's built on, and revisiting that signal periodically matters more than getting the initial threshold perfect.&lt;/p&gt;

&lt;h2&gt;
  
  
  A secondary check we added after the fact
&lt;/h2&gt;

&lt;p&gt;A few weeks after switching the primary signal, we added one more check almost as an afterthought: comparing the number of active workers against the number workers actually being utilized, versus sitting idle waiting on a lock or a downstream dependency. It turned out a meaningful chunk of the "capacity" the old autoscaler had added during those afternoon spikes was never doing useful work in the first place, workers were up and billed for, but blocked waiting on a database connection pool that hadn't scaled alongside them. That's a distinct problem from the metric itself, but it's exactly the kind of thing that only becomes visible once you stop trusting the first number that looks like it explains the spike.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If your autoscaling rule reads a single blended queue-depth number, it's worth checking what's actually driving the spikes before adding a scaling threshold on top of it. A number that goes up because a scheduled batch ran and a number that goes up because users are waiting look identical in a total count and require completely different responses. Splitting metrics by job type and switching to age instead of volume is usually a smaller change than it sounds, and it's the kind of instrumentation work &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry's automation team&lt;/a&gt; ends up doing on most client job systems eventually, once the blended metric stops telling the operator anything useful.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>backend</category>
      <category>programming</category>
      <category>automation</category>
    </item>
    <item>
      <title>How to Version Job Payloads So Schema Changes Don't Break In-Flight Work</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Mon, 27 Jul 2026 11:18:47 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-version-job-payloads-so-schema-changes-dont-break-in-flight-work-2m8c</link>
      <guid>https://dev.to/137foundry/how-to-version-job-payloads-so-schema-changes-dont-break-in-flight-work-2m8c</guid>
      <description>&lt;p&gt;A deploy that changes a job payload's shape while old-format jobs are still sitting in the queue is a quiet way to lose work. The consumer expects the new fields, the old jobs don't have them, and depending on how defensively the handler was written, that either crashes outright or silently processes bad data. Here's a step-by-step way to version payloads so this stops being a risk every deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Add an explicit version field to every payload, starting now
&lt;/h2&gt;

&lt;p&gt;Even if you've never versioned anything before, the first step costs nothing: add a &lt;code&gt;schema_version&lt;/code&gt; field set to &lt;code&gt;1&lt;/code&gt; on every job payload going forward. Jobs enqueued before this change simply won't have the field, which the consumer can treat as an implicit version zero. This single field is what makes every later step possible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_confirmation&lt;/span&gt;&lt;span class="sh"&gt;"&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;
  
  
  Step 2: Branch the consumer on version, not on field presence
&lt;/h2&gt;

&lt;p&gt;The tempting shortcut is checking &lt;code&gt;if "new_field" in payload&lt;/code&gt; instead of checking a version number directly. That works until a future schema change adds a field with the same name but a different meaning, or removes a field the check depended on. Branching explicitly on &lt;code&gt;schema_version&lt;/code&gt; keeps the logic legible and makes it obvious which code path handles which era of the payload.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema_version&lt;/span&gt;&lt;span class="sh"&gt;"&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;process_v0&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;process_v1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown schema_version: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Write an upgrade function instead of duplicating handler logic
&lt;/h2&gt;

&lt;p&gt;Once you're past two versions, duplicating full handler logic per version gets unmanageable fast. A cleaner pattern is a small upgrade function per version bump that transforms an old payload shape into the next version's shape, then always processes the latest version. The handler logic itself stays written once, against the current schema.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upgrade_v0_to_v1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempts&lt;/span&gt;&lt;span class="sh"&gt;"&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="c1"&gt;# renamed field
&lt;/span&gt;    &lt;span class="k"&gt;del&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema_version&lt;/span&gt;&lt;span class="sh"&gt;"&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="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;CURRENT_VERSION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;UPGRADERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;process_current&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Never deploy a breaking consumer change while old-format jobs might still be in flight
&lt;/h2&gt;

&lt;p&gt;The riskiest deploy pattern is shipping a consumer that only understands the new schema while jobs in the queue still use the old one. If a schema change is happening the same day as a deploy, drain the queue first, or ship the upgrade function from Step 3 in the same deploy so the new consumer can still process old payloads. Either approach avoids the gap where in-flight jobs simply fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Keep the upgrade chain, don't delete old handlers immediately
&lt;/h2&gt;

&lt;p&gt;It's tempting to delete &lt;code&gt;process_v0&lt;/code&gt; the moment nothing is enqueuing v0 payloads anymore, but a job that's been sitting in a dead-letter table for two weeks and gets manually replayed later will still have the old shape. Keep old version handlers around for at least as long as your dead-letter retention window, and only remove them once you've confirmed nothing in that table still references the old version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5a: Handle the reverse case, a new consumer reading an old queue during a rollback
&lt;/h2&gt;

&lt;p&gt;The upgrade chain above assumes deploys only move forward, but rollbacks happen. If a bad deploy needs to be rolled back after it's already started enqueuing v2 payloads, the previous consumer version, which only knows about v0 and v1, will hit payloads it's never seen. Guard against this by having the enqueue side, not just the consumer side, check what schema version the currently deployed consumer fleet actually supports before writing a new-format payload, or by keeping the upgrade chain one version ahead of what you're actively enqueuing so a rollback still has a valid path forward.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;MAX_SUPPORTED_VERSION_ENV&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CONSUMER_MAX_SCHEMA_VERSION&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;safe_enqueue_version&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="c1"&gt;# Never enqueue a version newer than what's actually deployed and running
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CURRENT_VERSION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAX_SUPPORTED_VERSION_ENV&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CURRENT_VERSION&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6: Log the version distribution during a migration
&lt;/h2&gt;

&lt;p&gt;While an upgrade chain is actively in use, add a simple counter or log line recording which version each processed job arrived at. This gives a concrete signal for when it's actually safe to retire an old handler, once the count of jobs arriving at version zero drops to zero for a sustained period, instead of guessing based on how long ago the schema changed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;processing schema_version=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;schema_version&lt;/span&gt;&lt;span class="sh"&gt;'&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="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 7: Treat the upgrade chain like production code, with its own tests
&lt;/h2&gt;

&lt;p&gt;It's easy to write &lt;code&gt;upgrade_v0_to_v1&lt;/code&gt; once, confirm it works against one sample payload, and never touch it again. Give the upgrade chain its own test suite that feeds in real historical payload shapes (pull a handful from your dead-letter table or logs if you have them) and asserts the output matches what the current handler expects. Schema changes tend to arrive in a rush right before a deploy, which is exactly when an untested upgrade function is most likely to have a subtle bug nobody catches until a real old-format job hits it in production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_upgrade_chain_handles_real_v0_payload&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;old_payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;abc123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_confirmation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;upgraded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;upgrade_v0_to_v1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;old_payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;upgraded&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema_version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;upgraded&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;upgraded&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Where this fits with the rest of the queue
&lt;/h2&gt;

&lt;p&gt;Payload versioning solves a different problem than queue durability, but the two show up in the same systems for the same reason: once a job queue has been running in production for a while, both old-format jobs and crash recovery become things that actually happen rather than edge cases in a design doc. We covered the durability half, keeping a job from disappearing if a worker restarts mid-task, in &lt;a href="https://137foundry.com/articles/background-job-queue-survives-server-restart" rel="noopener noreferrer"&gt;a longer guide on building a background job queue that survives a server restart&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Most of the tooling here works the same regardless of whether the queue is backed by &lt;a href="https://redis.io" rel="noopener noreferrer"&gt;Redis&lt;/a&gt;, a plain &lt;a href="https://www.postgresql.org" rel="noopener noreferrer"&gt;PostgreSQL&lt;/a&gt; table, or a managed broker like &lt;a href="https://cloud.google.com/pubsub" rel="noopener noreferrer"&gt;Google Cloud Pub/Sub&lt;/a&gt;; what matters is the discipline of never assuming every payload the consumer sees matches the schema you just shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smallest version of this worth doing on day one
&lt;/h2&gt;

&lt;p&gt;If your job system is brand new and none of this feels urgent yet, the one step worth doing regardless is Step 1: add the &lt;code&gt;schema_version&lt;/code&gt; field now, even set to a constant &lt;code&gt;1&lt;/code&gt; with no branching logic behind it yet. Retrofitting a version field onto payloads that have already shipped without one means every historical job in flight or sitting in a dead-letter table is permanently unversioned, which turns any future schema change into a guessing game about what shape old data might be in. The field costs nothing to add early and becomes expensive to add after the fact.&lt;/p&gt;

&lt;p&gt;We've seen the retrofit version of this problem play out on more than one client project: a job system that ran for a year or two with no version field at all, followed by a schema change that had no reliable way to tell an old payload from a new one, forcing the team to fall back on brittle heuristics like checking whether a specific field happened to be present. Every one of those heuristics eventually misclassified at least one real payload. A version field from day one turns that entire class of guesswork into a single integer comparison.&lt;/p&gt;

&lt;p&gt;If your team is planning a schema change to a production job queue, &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry&lt;/a&gt; can help map out the upgrade path before the deploy goes out, not after the first in-flight job fails on it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>backend</category>
      <category>programming</category>
      <category>node</category>
    </item>
    <item>
      <title>How to Add Correlation IDs to Background Job Logs So You Can Actually Trace a Failure</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Sun, 26 Jul 2026 11:22:48 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-add-correlation-ids-to-background-job-logs-so-you-can-actually-trace-a-failure-m9h</link>
      <guid>https://dev.to/137foundry/how-to-add-correlation-ids-to-background-job-logs-so-you-can-actually-trace-a-failure-m9h</guid>
      <description>&lt;p&gt;A job fails at 2am. The alert fires. You open the logs and find a stack trace with no order ID, no user ID, no request context, just an exception and a timestamp. Somewhere upstream, an API request triggered this job, and somewhere downstream, three other jobs it queued are probably also going to fail, but nothing in any of these log lines connects them to each other. This is the exact moment a correlation ID would have turned a two-hour investigation into a five-minute one.&lt;/p&gt;

&lt;p&gt;Here is how to actually add correlation IDs to a background job system, not just the concept, the specific steps that make it work once jobs start queuing other jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: generate the ID at the earliest possible point
&lt;/h2&gt;

&lt;p&gt;The correlation ID needs to originate at the very start of the request or event that eventually triggers background work, not inside the job handler itself. If a web request creates a job, generate the ID when the request comes in, before any business logic runs. If an event from another system triggers a job, generate the ID as soon as that event is received, or better, use an ID the upstream system already generated if one exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;correlation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Correlation-ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="c1"&gt;# ... rest of request handling
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Preferring an existing header over generating a new one matters once you have multiple services in the request path. If service A already assigned a correlation ID and service B generates its own instead of reusing it, you've broken the chain at exactly the boundary where tracing across services matters most.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: pass the ID into the job payload explicitly
&lt;/h2&gt;

&lt;p&gt;The correlation ID needs to travel with the job as data, not as some kind of ambient context that gets lost the moment the job crosses from the web process into a worker process. Add it as an explicit field on every job payload.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;enqueue_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_confirmation_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;correlation_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;correlation_id&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;This step gets skipped more often than it sounds like it should, usually because the job payload was designed before anyone thought about tracing, and adding a field to every enqueue call feels like unnecessary boilerplate until the first incident where you need it and it isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: attach the ID to every log line inside the worker
&lt;/h2&gt;

&lt;p&gt;Once the worker picks up the job, the correlation ID needs to end up in every single log statement that job produces, not just the first one. Most logging libraries support this through a context variable or a logger adapter that automatically injects a field into every subsequent log call within a scope.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;contextvars&lt;/span&gt;

&lt;span class="n"&gt;correlation_id_var&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;contextvars&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ContextVar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;correlation_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CorrelationFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Filter&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;correlation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;correlation_id_var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the context variable once at the top of the job handler, and every log call inside that handler and any function it calls picks up the value automatically, without threading the correlation ID through every function signature by hand. Most mainstream logging setups, including the standard library logging module in &lt;a href="https://www.python.org" rel="noopener noreferrer"&gt;Python&lt;/a&gt;, support this filter-based approach without needing a third-party dependency.&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%2Fimages.unsplash.com%2Fphoto-1679119790850-161688b0417e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5MzI0MTZ8MHwxfHNlYXJjaHw0fHxub3RlYm9vayUyMGFubm90YXRlZCUyMGRpYWdyYW1zJTIwcGVufGVufDF8fHx8MTc4NTA2NDEwMHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" 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%2Fimages.unsplash.com%2Fphoto-1679119790850-161688b0417e%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3w5MzI0MTZ8MHwxfHNlYXJjaHw0fHxub3RlYm9vayUyMGFubm90YXRlZCUyMGRpYWdyYW1zJTIwcGVufGVufDF8fHx8MTc4NTA2NDEwMHww%26ixlib%3Drb-4.1.0%26q%3D80%26w%3D1080" alt="Notebook page covered in annotated diagrams and pen marks" width="1080" height="1623"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by &lt;a href="https://unsplash.com/@ryunosuke_kikuno?utm_source=137foundry&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Ryunosuke Kikuno&lt;/a&gt; on &lt;a href="https://unsplash.com?utm_source=137foundry&amp;amp;utm_medium=referral" rel="noopener noreferrer"&gt;Unsplash&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: propagate the ID when a job enqueues more jobs
&lt;/h2&gt;

&lt;p&gt;This is the step that determines whether the whole system actually works end to end. If job A enqueues job B, job B needs the same correlation ID, not a new one. Without this, you can trace the first hop of a chain and lose the thread the moment one job spawns another, which is exactly the scenario, one failure cascading into several, where tracing matters most.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;correlation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;correlation_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;enqueue_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_confirmation_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;correlation_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;correlation_id&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;Audit every place in your codebase where one job type enqueues another and confirm the correlation ID is being forwarded, not silently dropped because the enqueue call was written before this pattern existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: make the ID searchable in whatever you use to view logs
&lt;/h2&gt;

&lt;p&gt;A correlation ID is only useful if you can actually query by it. If logs are structured and shipped to something like Elasticsearch or a hosted logging platform, the field needs to be indexed, not buried inside an unstructured message string. If you're on something simpler, grepping a centralized log file for the ID works fine as long as every relevant log line actually contains it, which is the entire point of steps one through four.&lt;/p&gt;

&lt;p&gt;Tools built around &lt;a href="https://opentelemetry.io" rel="noopener noreferrer"&gt;OpenTelemetry&lt;/a&gt; take this further with distributed tracing that automatically threads context across service boundaries, which is worth adopting once a correlation ID convention alone stops being enough for a system with many interacting services. The &lt;a href="https://www.w3.org/TR/trace-context/" rel="noopener noreferrer"&gt;W3C Trace Context specification&lt;/a&gt; documents the standard header format this tooling is built around, and it's worth reading even if you're not using a tracing library yet, because the header format is a reasonable convention to adopt for your own correlation IDs.&lt;/p&gt;

&lt;p&gt;Even without adopting a full tracing library, a manual correlation ID convention is worth keeping in place long term, since it degrades gracefully in exactly the situations where automated tracing tends to fall over, a service that hasn't been instrumented yet, a background job triggered by a cron schedule rather than a traced request, or a quick script run manually during an incident where nobody has time to wire up a tracer first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more once your queue is durable
&lt;/h2&gt;

&lt;p&gt;A durable job queue means failed jobs get retried instead of vanishing, which is good for reliability and bad for debugging if you can't tell which retry attempt you're looking at or which original request kicked off the chain. We covered building that durability layer, keeping job state outside process memory so a crash doesn't lose work, in &lt;a href="https://137foundry.com/articles/background-job-queue-survives-server-restart" rel="noopener noreferrer"&gt;a longer piece on building a job queue that survives a server restart&lt;/a&gt;. Correlation IDs are the piece that makes debugging that durable system tractable once jobs start failing and retrying at scale, rather than staying a single clean happy path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start if you have none of this today
&lt;/h2&gt;

&lt;p&gt;Don't try to retrofit correlation IDs across an entire system in one pass. Start with the request path or event type that generates the most support tickets when something goes wrong, add the ID at the entry point, thread it through the first hop of jobs it triggers, and confirm you can actually find a specific incident's full trail in your logs before expanding to the rest of the system. This kind of tracing infrastructure is a common piece of the backend work &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry's engineering team&lt;/a&gt; handles for client teams once a job system has grown past the point where a single log line tells the whole story.&lt;/p&gt;

&lt;p&gt;A good test of whether the work is actually done: pick a recent, real incident and try to reconstruct its full trail using only the correlation ID and your existing log search. If that takes more than a couple of minutes, or if the trail goes cold at some hop where a job triggered another job without forwarding the ID, that's the specific gap worth closing next, rather than a sign the whole approach needs rethinking.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>backend</category>
      <category>logging</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why At Least Once Delivery Breaks Assumptions Most Teams Never Check</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Sun, 26 Jul 2026 11:22:42 +0000</pubDate>
      <link>https://dev.to/137foundry/why-at-least-once-delivery-breaks-assumptions-most-teams-never-check-44ko</link>
      <guid>https://dev.to/137foundry/why-at-least-once-delivery-breaks-assumptions-most-teams-never-check-44ko</guid>
      <description>&lt;p&gt;Most job queues and message brokers advertise "at least once" delivery as a feature. It is, compared to the alternative of losing messages silently. What gets left out of the marketing is the second half of the sentence: at least once means a message can and eventually will be delivered more than once, and every handler that assumes otherwise is carrying a bug that just hasn't triggered yet.&lt;/p&gt;

&lt;p&gt;This isn't a rare edge case. It's the normal, expected behavior of any durable queue with a visibility timeout, a retry policy, or a consumer that acknowledges messages after processing rather than before. The gap between knowing this abstractly and actually auditing your handlers for it is where most production incidents in this category come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the assumption sneaks in
&lt;/h2&gt;

&lt;p&gt;The most common failure mode looks like this: a worker pulls a job, starts processing, and completes the side effect, an email sent, a charge captured, a row updated, but crashes or times out before it acknowledges the message back to the queue. From the queue's perspective, the message was never confirmed as processed, so it gets redelivered. From the handler's perspective, if it isn't written defensively, that redelivery means the side effect happens again.&lt;/p&gt;

&lt;p&gt;Nobody sits down and decides to build a handler that breaks on duplicate delivery. It happens because the happy path, one message, one execution, one visible result, works fine in every test and in every demo, and the duplicate-delivery case only shows up under real network conditions, real timeouts, and real worker crashes that local development rarely reproduces.&lt;/p&gt;

&lt;p&gt;Worse, the duplicate-delivery case is often rare enough in absolute terms, maybe a fraction of a percent of all jobs, that it takes weeks or months of production traffic before anyone notices a pattern, and by then the handler has usually been copied as a template for several other job types built the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where at-least-once semantics actually come from
&lt;/h2&gt;

&lt;p&gt;Almost every message broker in production use today, &lt;a href="https://kafka.apache.org" rel="noopener noreferrer"&gt;Kafka&lt;/a&gt;, &lt;a href="https://www.rabbitmq.com" rel="noopener noreferrer"&gt;RabbitMQ&lt;/a&gt;, Redis-backed queues, cloud provider queue services, guarantees at-least-once delivery by design, not as a bug. The mechanism is consistent across all of them: a consumer receives a message, the broker starts a redelivery timer, and if the consumer doesn't acknowledge within that window, the broker assumes something went wrong and redelivers. That's a deliberate tradeoff favoring "the message definitely got processed eventually" over "the message got processed exactly once," because guaranteeing exactly-once delivery across a network boundary is a much harder problem, and most systems that claim to solve it are actually doing at-least-once delivery plus idempotent processing underneath.&lt;/p&gt;

&lt;p&gt;This is worth explaining to a team explicitly rather than assuming everyone already knows it, because the phrase "exactly once" shows up often enough in marketing copy and conference talks that engineers reasonably assume some systems have actually solved the harder problem. In practice, the honest framing is closer to "at least once delivery, with tooling that makes idempotent processing easier to implement correctly," and understanding that distinction changes how a team designs handlers from the start rather than discovering the gap after an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is not a smarter broker
&lt;/h2&gt;

&lt;p&gt;Teams sometimes go looking for an "exactly once" queue technology, assuming the current stack is just missing a feature. In practice, true exactly-once delivery requires either a single atomic operation spanning the message read and the side effect, which most systems can't offer across arbitrary external calls, or idempotent handlers that make duplicate delivery harmless regardless of how many times a message arrives. The second approach is the one that actually scales to real systems with external side effects like emails, webhooks, and payment charges.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_order_confirmation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;already_sent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="nf"&gt;send_confirmation_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;mark_sent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pattern looks trivial written out, and the mechanics of doing it correctly under concurrent retries, where two workers might both check &lt;code&gt;already_sent&lt;/code&gt; before either has recorded a result, take more care than the snippet suggests. A unique constraint on whatever table tracks "already sent" state, checked as part of the same write rather than a separate read-then-write, is what actually closes the race.&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%2Fc14m809wmk9aoong57sq.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%2Fc14m809wmk9aoong57sq.jpeg" alt="Data center hallway lined with rows of servers" width="800" height="1200"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by Oktay Köseoğlu on &lt;a href="https://www.pexels.com" rel="noopener noreferrer"&gt;Pexels&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The check-then-act version, a &lt;code&gt;SELECT&lt;/code&gt; followed by an &lt;code&gt;INSERT&lt;/code&gt; in separate statements, looks correct in a single-threaded test and fails exactly the moment two workers happen to run it within milliseconds of each other, which is precisely the scenario that redelivery creates. Combining the check and the write into one atomic statement, using a unique constraint and catching the conflict, is the difference between a fix that works in theory and one that actually holds up under real concurrent retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auditing your own handlers for this
&lt;/h2&gt;

&lt;p&gt;Go through every job handler that has an external side effect and ask, specifically, what happens if this exact message arrives twice within a few seconds of each other. Emails get duplicated. Charges get captured twice. Webhooks fire twice to a partner system that might not deduplicate on their end either. Database writes that aren't naturally idempotent, incrementing a counter rather than setting a value, are a particularly easy trap to miss because the bug doesn't show up until the second delivery actually happens, which might be days after the handler shipped.&lt;/p&gt;

&lt;p&gt;Handlers that are naturally idempotent, recalculating a value from source data, upserting a row keyed on a stable identifier, don't need any extra work. The audit is really about finding the subset of handlers that aren't naturally safe and adding an explicit guard to each one.&lt;/p&gt;

&lt;p&gt;A quick heuristic that speeds this up: if a handler's core action is a &lt;code&gt;SET&lt;/code&gt; or an &lt;code&gt;UPSERT&lt;/code&gt;, it's probably already safe. If it's an &lt;code&gt;INCREMENT&lt;/code&gt;, an &lt;code&gt;APPEND&lt;/code&gt;, or anything that sends a request to an external system, it almost certainly needs an explicit guard, because none of those operations are naturally idempotent on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for queue design generally
&lt;/h2&gt;

&lt;p&gt;We covered the durability side of this problem, keeping job state outside process memory so a crash doesn't lose work entirely, in &lt;a href="https://137foundry.com/articles/background-job-queue-survives-server-restart" rel="noopener noreferrer"&gt;our guide to building a job queue that survives a server restart&lt;/a&gt;. At-least-once delivery is the natural consequence of that durability, not a separate design choice, and treating it as an afterthought is how teams end up with a queue that's technically reliable and a set of handlers that quietly misbehave under load.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://en.wikipedia.org/wiki/Idempotence" rel="noopener noreferrer"&gt;Wikipedia entry on idempotence&lt;/a&gt; is a useful shorthand to hand to a team that hasn't internalized this yet: an operation is idempotent if applying it multiple times produces the same result as applying it once. That's the property every side-effecting handler in a queue needs, and it's worth stating explicitly in code review rather than assuming everyone already thinks about it that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we see this most often
&lt;/h2&gt;

&lt;p&gt;This comes up constantly in the backend architecture reviews &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry&lt;/a&gt; runs for client teams, almost always in a handler nobody thought to question because it had "always worked," meaning it had simply never been redelivered yet in a way anyone noticed. If your job system has been running for a while without an explicit idempotency audit, it's worth doing one before the first duplicate charge or duplicate email turns it into a support ticket instead of a code review comment.&lt;/p&gt;

&lt;p&gt;Start with the handlers that would actually hurt if they ran twice. Everything else can wait.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>backend</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
