<?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>5 Free Resources for Designing Better Mobile Empty States</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Fri, 11 Sep 2026 11:14:16 +0000</pubDate>
      <link>https://dev.to/137foundry/5-free-resources-for-designing-better-mobile-empty-states-l62</link>
      <guid>https://dev.to/137foundry/5-free-resources-for-designing-better-mobile-empty-states-l62</guid>
      <description>&lt;p&gt;Empty states get designed last, if they get designed at all. A screen with zero data, zero results, or a first-run state before the user has done anything usually inherits whatever the developer typed as a placeholder, and it shows. These seven resources make it easy to do better without hiring a full-time illustrator, and none of them require a paid subscription to get real value out of.&lt;/p&gt;

&lt;p&gt;Empty states matter more than their small footprint in the product suggests. They're often the very first screen a new user sees after completing onboarding, before they've added any of their own content, which means a generic or broken-feeling empty state can undercut an otherwise well-designed onboarding flow at the last step. Getting this one screen right pays off disproportionately relative to the effort it takes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Mobbin: A Real-World Pattern Library
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://mobbin.com/" rel="noopener noreferrer"&gt;Mobbin&lt;/a&gt; catalogs actual screens from shipped apps, organized by flow and pattern, including empty states, onboarding, and permission moments. Instead of guessing how a category leader handles a zero-results search screen, you can look at dozens of real examples side by side and see what the pattern actually looks like in production, not in a mockup.&lt;/p&gt;

&lt;p&gt;This matters especially for empty states because the good ones are subtle. A screenshot in a design article rarely captures the microcopy, spacing, and illustration choices that make a real empty state feel intentional rather than apologetic. Browsing real shipped screens closes that gap fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Nielsen Norman Group's Usability Research
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.nngroup.com/" rel="noopener noreferrer"&gt;Nielsen Norman Group&lt;/a&gt; publishes free research on how users actually respond to empty and error states, backed by usability testing rather than aesthetic opinion. Their guidance on writing helpful, action-oriented copy for zero-data screens is directly applicable whether you're designing a search-with-no-results state or a brand new user's dashboard before they've added anything.&lt;/p&gt;

&lt;p&gt;The research angle is what separates this resource from a pure inspiration gallery. It tells you why certain phrasing or layout choices measurably reduce confusion and abandonment, which is more useful than a collection of pretty screenshots when you're trying to justify a design decision to a product manager.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Material Design's Empty State Guidance
&lt;/h2&gt;

&lt;p&gt;Google's &lt;a href="https://material.io/" rel="noopener noreferrer"&gt;Material Design&lt;/a&gt; system includes explicit component and layout guidance for empty states as a first-class pattern, not an afterthought. Even teams not building on Material components benefit from reading how the system thinks about the anatomy of a good empty state: a clear illustration or icon, a short explanation, and a single obvious next action.&lt;/p&gt;

&lt;p&gt;The emphasis on exactly one clear call to action is worth internalizing regardless of your design system. Empty states that offer three competing buttons dilute the one action that actually moves the user forward, usually "add your first item" or equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Apple's Human Interface Guidelines
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://developer.apple.com/" rel="noopener noreferrer"&gt;Apple's developer documentation&lt;/a&gt; covers empty and loading states as part of its broader interface guidance, with a consistent emphasis on treating a zero-data screen as an opportunity to explain the feature rather than a dead end. The framing they push toward, explaining what will appear here once the user takes an action, applies well beyond iOS.&lt;/p&gt;

&lt;p&gt;Reading platform guidelines even when you're building for both iOS and Android surfaces conventions users already expect from other apps on the platform, which reduces the learning curve for your specific screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. LottieFiles for Lightweight Empty State Animation
&lt;/h2&gt;

&lt;p&gt;A static illustration is fine, but a small, lightweight animation can make an empty state feel alive rather than broken, especially on a first-run screen. &lt;a href="https://lottiefiles.com/" rel="noopener noreferrer"&gt;LottieFiles&lt;/a&gt; hosts a large library of free, small-file-size animations built on the open Lottie format, which renders natively on both iOS and Android without the overhead of a video file.&lt;/p&gt;

&lt;p&gt;The key constraint worth respecting: keep any empty-state animation short, subtle, and skippable. An animation that loops aggressively or blocks the layout from loading defeats the purpose of making the screen feel lighter, not heavier.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Figma's Community Files for Empty State Patterns
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.figma.com/" rel="noopener noreferrer"&gt;Figma&lt;/a&gt; hosts a large ecosystem of free, community-published design files, many of which are built specifically around common mobile UI patterns including empty states, onboarding carousels, and error screens. Searching the community library for "empty state" turns up dozens of ready-made layouts you can duplicate and adapt rather than building illustration and copy layout decisions from a blank canvas.&lt;/p&gt;

&lt;p&gt;The value here isn't copying a specific file wholesale. It's seeing how other designers have solved the same layout problem, illustration placement, headline length, button prominence, across a wide enough sample that patterns start to emerge, which is a faster way to internalize good defaults than reading a single style guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Android's Empty State Design Guidance
&lt;/h2&gt;

&lt;p&gt;Beyond the general Material Design system, &lt;a href="https://developer.android.com/" rel="noopener noreferrer"&gt;Android's developer documentation&lt;/a&gt; includes specific guidance on empty view handling within RecyclerViews and list-based screens, covering both the visual pattern and the underlying implementation approach for showing and hiding empty state views correctly as data loads. This is useful specifically because empty states often have a technical implementation trap alongside the design one: showing an empty state briefly during a normal loading flicker, before real data arrives, creates a worse experience than no empty state at all.&lt;/p&gt;

&lt;p&gt;Getting the loading-versus-genuinely-empty distinction right at the implementation level is just as important as getting the illustration and copy right, and this is one of the few resources that addresses both halves of the problem together.&lt;/p&gt;

&lt;h2&gt;
  
  
  How These Resources Fit Together in Practice
&lt;/h2&gt;

&lt;p&gt;None of these seven resources are meant to be used in isolation. A typical workflow we'd recommend: start with Mobbin to see how real, shipped apps in your category handle the specific empty state you're designing, cross-reference the Material Design or Apple guidance for the platform-level conventions users already expect, pull research from Nielsen Norman Group when you need to justify a specific copy or layout decision to stakeholders, and use Figma community files or LottieFiles when you need an actual visual asset rather than just guidance.&lt;/p&gt;

&lt;p&gt;Treating these as a toolkit rather than picking just one tends to produce a better result than any single resource alone, since each covers a different part of the problem: real-world precedent, platform convention, research justification, and production-ready assets. Teams that skip straight to building custom illustrations from scratch often end up re-solving layout and copy problems that the research and pattern-library resources above have already worked out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Empty States Deserve the Same Rigor as Permission Prompts
&lt;/h2&gt;

&lt;p&gt;Empty states and permission requests get dismissed for similar reasons: users encounter a screen that doesn't explain itself and assume the worst. The same discipline that goes into timing a permission prompt and explaining it clearly before the system dialog fires applies directly to a zero-data screen. Both are moments where a user is deciding whether to trust the app enough to keep going.&lt;/p&gt;

&lt;p&gt;For a deeper look at getting that trust-building moment right at the permission-request level specifically, &lt;a href="https://137foundry.com/articles/app-permission-requests-users-actually-grant" rel="noopener noreferrer"&gt;this guide on designing permission requests users actually grant&lt;/a&gt; walks through timing, framing, and denial recovery in detail. Teams rebuilding these secondary states as part of a larger product design pass often lean on specialists like &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry's web development team&lt;/a&gt;, who treat empty states, onboarding, and permission flows as one connected system rather than separate tickets.&lt;/p&gt;

&lt;p&gt;None of these resources require a full redesign sprint to start using. Pick the one or two empty states in your own app that see the most new-user traffic, a search-with-no-results screen or a first-run dashboard are usually the highest-impact candidates, and start there rather than trying to rebuild every zero-data screen across the product at once. Small, targeted improvements to the highest-traffic empty states tend to move user perception more than a comprehensive pass applied unevenly across screens nobody actually lands on.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>mobile</category>
      <category>design</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How to Design Deep Links That Survive App Updates and Cold Starts</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Fri, 11 Sep 2026 11:14:14 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-design-deep-links-that-survive-app-updates-and-cold-starts-255</link>
      <guid>https://dev.to/137foundry/how-to-design-deep-links-that-survive-app-updates-and-cold-starts-255</guid>
      <description>&lt;p&gt;A deep link that only works when the app is already running, or that dumps the user on the home screen instead of the specific content it promised, undoes whatever campaign or notification sent them there in the first place. Getting deep linking right means handling cold starts, updates, and missing content explicitly, not just the happy path where the app is already open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Separate Routing From Screen Navigation
&lt;/h2&gt;

&lt;p&gt;The most common deep linking bug comes from tightly coupling the link parser to whatever navigation stack happens to exist at that moment. Build a dedicated routing layer that parses the incoming URL or universal link into a structured destination first, completely independent of whether the app is cold-starting, backgrounded, or already on the target screen.&lt;/p&gt;

&lt;p&gt;This separation is what lets the same routing logic handle a cold start, a warm resume, and an in-app navigation event without three separate code paths. Android's documentation on &lt;a href="https://developer.android.com/" rel="noopener noreferrer"&gt;deep linking&lt;/a&gt; and Apple's &lt;a href="https://developer.apple.com/" rel="noopener noreferrer"&gt;universal links guidance&lt;/a&gt; both structure their recommended approach around this same principle: resolve the destination first, then navigate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Handle the Cold Start Case Explicitly
&lt;/h2&gt;

&lt;p&gt;When a user taps a deep link and the app isn't running, the operating system has to launch it fresh before any navigation can happen. If your routing logic assumes the app's normal state, like an authenticated session or a loaded dataset, is already present, cold-start deep links will silently fail or crash.&lt;/p&gt;

&lt;p&gt;Queue the intended destination during cold start and resolve it only after your app has finished its normal startup sequence, including authentication checks and any required data loading. This queuing step is easy to skip in testing because developers rarely cold-start the app the way a real user tapping a link from a text message does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Design a Clear Fallback for Missing or Expired Content
&lt;/h2&gt;

&lt;p&gt;Deep links often point at content that no longer exists by the time someone taps it, a deleted post, an expired promotion, a canceled event. The fallback experience matters as much as the happy path. Route to a relevant parent screen with a clear explanation, never a generic error or a silent redirect to the home screen with no context.&lt;/p&gt;

&lt;p&gt;A user who taps a link expecting a specific item and lands on an unexplained home screen assumes the link, and often the app, is broken. A user who lands on "this item is no longer available, browse similar items here" understands what happened and stays engaged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Version Your Link Schema So App Updates Don't Break Old Links
&lt;/h2&gt;

&lt;p&gt;Links generated by your backend, shared in emails, or embedded in push notifications sent weeks ago need to keep working after your app ships new versions. Treat your deep link schema the way you'd treat a public API: version it explicitly, and maintain backward compatibility for old link formats rather than assuming every link in the wild points at the current app version.&lt;/p&gt;

&lt;p&gt;This becomes especially important around major navigation restructures. If a redesign changes what a given screen ID maps to, old links pointing at the previous structure need a translation layer, not a silent break.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Test Cross-Platform Link Behavior, Not Just In-App Navigation
&lt;/h2&gt;

&lt;p&gt;Universal links and app links have platform-specific quirks around domain verification, associated app files, and fallback behavior when the app isn't installed at all. A link that works perfectly when tapped from within your own app's test harness can fail entirely when tapped from a real browser, a messaging app, or an email client, each of which handles link resolution slightly differently.&lt;/p&gt;

&lt;p&gt;Test the actual end-to-end path: tap the link from the real source it will appear in, on a device without the app installed, to confirm the web fallback works, and again with the app installed to confirm it opens directly to the right screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Log Deep Link Resolution the Same Way You Log Any Funnel
&lt;/h2&gt;

&lt;p&gt;Instrument every stage of deep link resolution: link received, destination parsed, content found or not found, screen rendered. Without this, a broken deep link often shows up only as an unexplained drop in a marketing campaign's conversion numbers, with no visibility into whether the problem was the link itself, the content, or the app's handling of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Follow Standard URI Conventions Instead of Inventing Your Own
&lt;/h2&gt;

&lt;p&gt;Custom URL schemes that don't follow standard URI structure, inconsistent encoding, ambiguous parameter ordering, missing scheme registration, cause a disproportionate share of deep linking bugs. The &lt;a href="https://www.w3.org/" rel="noopener noreferrer"&gt;W3C's URI specification work&lt;/a&gt; documents the conventions that most tooling, link shorteners, and messaging apps assume when they parse a URL, and deviating from those conventions is a common source of links that render correctly in your own testing but break when passed through a third-party service.&lt;/p&gt;

&lt;p&gt;Sticking to standard percent-encoding for parameters, consistent path structure, and predictable query string formatting isn't just about correctness. It also means the wide ecosystem of tools that generate, shorten, and preview links will handle yours correctly without special-casing your app's particular scheme.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Handle Authentication State Inside the Routing Layer
&lt;/h2&gt;

&lt;p&gt;A deep link pointing at account-specific content, an order confirmation, a private message, a saved item, needs to check authentication state as part of routing, not as an afterthought once the destination screen has already started rendering. If the user isn't logged in, the routing layer should queue the intended destination, prompt for authentication, and resume the original navigation afterward rather than dropping the destination and stranding the user on a generic login screen.&lt;/p&gt;

&lt;p&gt;This queue-and-resume pattern is the same discipline described earlier for cold starts, applied to authentication instead of app launch. Both are cases where the eventual destination is known upfront but can't be reached immediately, and the routing layer needs to hold that intent until the blocking condition clears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: Preview and Test Links Exactly as They'll Be Shared
&lt;/h2&gt;

&lt;p&gt;Links shared through email, SMS, and social platforms often get wrapped, rewritten, or preview-scraped by the platform they're shared through before a user ever taps them. Testing only the raw link in isolation misses how it behaves after a messaging app's own link preview system has touched it. Generate a test link, actually share it through each real channel your marketing or notification system uses, and tap it from that real context rather than a browser address bar.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Quick Checklist Before You Ship a New Deep Link Type
&lt;/h2&gt;

&lt;p&gt;Before shipping any new deep link destination, it's worth running through a short checklist rather than assuming the happy path testing you already did is sufficient. Confirm the cold start case works from a genuinely killed app state, not just a backgrounded one. Confirm the missing-content fallback renders something useful rather than a blank or crashed screen. Confirm the link works when tapped from at least one real external source, not just your own test harness. And confirm old link formats from a previous app version, if any exist in the wild, still resolve correctly.&lt;/p&gt;

&lt;p&gt;Skipping this checklist is how deep linking bugs typically reach production: each individual piece works in isolation during development, but the combination of a real external trigger, a genuinely cold app, and slightly stale content exposes gaps that a quick in-app test never would have caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bringing Deep Linking Into the Broader Permission and Onboarding Picture
&lt;/h2&gt;

&lt;p&gt;Deep links frequently land a user on a screen that needs a permission grant, camera access for a scan-to-view feature, or location for a nearby-results link. When that happens, the same timing and framing principles that apply to any permission request apply here too, and a deep link that immediately demands a permission before showing any value tends to underperform one that shows the content first and asks only when needed.&lt;/p&gt;

&lt;p&gt;For the full breakdown of permission timing, priming, and denial recovery, see &lt;a href="https://137foundry.com/articles/app-permission-requests-users-actually-grant" rel="noopener noreferrer"&gt;this guide on designing app permission requests users actually grant&lt;/a&gt;. Teams building out a full deep linking and routing layer from scratch often bring in outside help for exactly this kind of cross-cutting architecture work, which is the kind of problem &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;this development team&lt;/a&gt; specializes in solving end to end.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>mobile</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why Soft Deletes Beat Hard Deletes for Anything Users Can Bulk-Select</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:42:56 +0000</pubDate>
      <link>https://dev.to/137foundry/why-soft-deletes-beat-hard-deletes-for-anything-users-can-bulk-select-f5a</link>
      <guid>https://dev.to/137foundry/why-soft-deletes-beat-hard-deletes-for-anything-users-can-bulk-select-f5a</guid>
      <description>&lt;p&gt;A hard delete on a single record is forgivable. Someone notices quickly, because one thing changed and it was probably the thing they were just looking at. A hard delete on two hundred records selected through a bulk action is a different order of problem entirely, and it's the exact scenario where "we'll just restore from backup" turns out to be a much worse plan than it sounded like in the design meeting.&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%2Fzg6xidefc7r9jhzcg6at.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%2Fzg6xidefc7r9jhzcg6at.jpeg" alt="rows of organized shelving in a warehouse storage facility" width="800" height="1067"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by WinSon 5293 on &lt;a href="https://www.pexels.com" rel="noopener noreferrer"&gt;Pexels&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Soft Delete Actually Buys You
&lt;/h2&gt;

&lt;p&gt;A soft delete marks a record as removed, usually with a boolean flag or a deleted-at timestamp, without running an actual delete statement against the database. The record stays exactly where it was, just excluded from normal queries. Reversing a soft delete is a single update statement. Reversing a hard delete requires a backup restore, and most teams' backup strategy is built around disaster recovery timelines measured in hours, not the "oh no, undo that" timeline of a few minutes that a bulk-action mistake actually needs.&lt;/p&gt;

&lt;p&gt;For anything reachable through a bulk-select interface, this difference is the whole argument. The blast radius of a bulk mistake is large by definition, that's what makes bulk actions valuable in the first place, and soft deletes are the cheapest way to make that blast radius survivable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Costs, and Why They're Usually Worth Paying
&lt;/h2&gt;

&lt;p&gt;Soft deletes aren't free. Every query against a soft-deletable table now needs a &lt;code&gt;WHERE deleted_at IS NULL&lt;/code&gt; clause, or an equivalent, and forgetting it in even one query path is a real bug class, deleted records silently reappearing somewhere they shouldn't. Both &lt;a href="https://www.postgresql.org" rel="noopener noreferrer"&gt;PostgreSQL&lt;/a&gt; and &lt;a href="https://www.mongodb.com" rel="noopener noreferrer"&gt;MongoDB&lt;/a&gt; handle this cleanly through views, default query scopes, or ORM-level global filters, but the discipline of applying that filter consistently across the codebase falls on your team, not the database.&lt;/p&gt;

&lt;p&gt;Storage is the other cost. Soft-deleted rows stick around until something actually purges them, and depending on your data retention obligations, that "something" needs to run on a schedule rather than never. This is where soft deletes intersect with actual policy, not just engineering convenience: how long is a deleted record kept before it's truly gone, and does that window satisfy whatever retention or right-to-erasure requirements apply to your data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Data Structure Behind It: Tombstones
&lt;/h2&gt;

&lt;p&gt;The general concept of marking data as deleted rather than removing it outright predates any specific ORM or framework. Distributed and replicated data stores have used &lt;a href="https://en.wikipedia.org/wiki/Tombstone_%28data_store%29" rel="noopener noreferrer"&gt;tombstone records&lt;/a&gt; for decades to handle exactly this problem: a delete needs to propagate correctly across replicas, and a tombstone, a marker saying "this was deleted, and here's when," is what makes that propagation reliable instead of ambiguous.&lt;/p&gt;

&lt;p&gt;Understanding the tombstone pattern is useful even outside a distributed-systems context, because it clarifies what a soft delete is actually for: not hiding data, but recording a definitive, queryable fact that a deletion happened, so every system touching that data can agree on the current state instead of guessing from absence.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a Hard Delete Is Still the Right Call
&lt;/h2&gt;

&lt;p&gt;None of this means hard deletes should never happen. Sensitive data that genuinely needs to be gone, for legal, security, or storage-cost reasons, eventually needs an actual purge, and that purge is a hard delete by definition. The difference is timing and intent: a bulk-action-triggered soft delete followed by a scheduled, policy-driven hard delete weeks later is a fundamentally safer sequence than making every user-triggered bulk action an immediate hard delete.&lt;/p&gt;

&lt;p&gt;Security and compliance guidance from bodies like the &lt;a href="https://www.nist.gov" rel="noopener noreferrer"&gt;National Institute of Standards and Technology&lt;/a&gt; generally treats data retention and disposal as a deliberate, auditable process rather than something that happens as a side effect of a UI button. Building your delete flow around that model, soft delete now, audited purge later, on your own schedule, aligns naturally with that expectation instead of fighting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling Unique Constraints and Foreign Keys
&lt;/h2&gt;

&lt;p&gt;One practical wrinkle soft deletes introduce is what happens to unique constraints. If a table enforces uniqueness on, say, an email address or a slug, and a soft-deleted row still occupies that value, a user trying to create a new record with the same email will hit a constraint violation even though the "old" record is supposed to be gone from their perspective. This trips up teams that add soft deletes to an existing schema without revisiting the constraints already in place.&lt;/p&gt;

&lt;p&gt;The common fix is a partial or conditional unique index, one that only enforces uniqueness among rows where &lt;code&gt;deleted_at IS NULL&lt;/code&gt;, so a soft-deleted record's old value frees up immediately for reuse while the deleted row itself keeps its original data intact for potential recovery. Both PostgreSQL and MongoDB support this kind of conditional indexing, though the exact syntax differs, and it's worth checking early rather than discovering the gap after a user complains they can't re-add a contact they just "deleted."&lt;/p&gt;

&lt;p&gt;Foreign keys carry a similar wrinkle. A soft-deleted parent record with child rows that reference it needs those child rows to still resolve correctly if the parent gets restored, which argues against ever converting a soft delete into a cascading hard delete on related tables without a deliberate purge step that also decides what should happen to everything that pointed at the deleted record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auditing Who Deleted What, and When
&lt;/h2&gt;

&lt;p&gt;A soft delete's timestamp tells you when a record was removed, but not by whom or in response to what action. For anything reachable through a bulk operation, pairing the soft-delete flag with a reference to the specific bulk action that triggered it, a batch ID, a user ID, and the filter or selection that produced the affected set, turns "we can restore this" into "we can also explain exactly what happened and why," which matters considerably more once more than one person could plausibly be responsible for a given deletion.&lt;/p&gt;

&lt;p&gt;This audit trail is also what makes a "recently deleted" recovery view trustworthy rather than confusing. Showing a user forty records they can restore is only useful if they can also see enough context, when, by what action, as part of what batch, to tell whether restoring all forty is actually what they want, versus restoring a smaller subset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building This Into a Bulk Action Without Overcomplicating It
&lt;/h2&gt;

&lt;p&gt;None of this requires a complicated system. A &lt;code&gt;deleted_at&lt;/code&gt; column, a query scope or default filter that every read path respects, a scheduled job that purges records past the retention window, and a "recently deleted" view where users can see and restore their own bulk-deleted items covers the vast majority of cases. The last piece, the visible recovery view, is what turns the safety net from an engineering detail nobody sees into an actual feature users notice and trust.&lt;/p&gt;

&lt;p&gt;For the rest of the bulk-action safety picture, confirmation dialogs, undo toasts, rate limiting the underlying batch, and accessible selection state, &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;https://137foundry.com&lt;/a&gt;'s longer guide on &lt;a href="https://137foundry.com/articles/bulk-actions-batch-editing-without-losing-data" rel="noopener noreferrer"&gt;designing bulk actions that don't destroy data&lt;/a&gt; walks through how soft deletes fit alongside those other patterns rather than replacing them. Soft deletes solve the "can we get it back" problem. The rest of that guide covers the "did we even mean to delete it" problem that comes before, and the two are worth designing together rather than treating either one as a complete solution on its own. A soft delete without a confirmation step just means mistakes happen more often but recover cleanly; a confirmation step without a soft delete means the rare mistake that slips through has no cheap way back. Neither substitutes for the other.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How to Build a Selection State Manager for Data Tables With Bulk Actions</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Thu, 10 Sep 2026 11:41:46 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-build-a-selection-state-manager-for-data-tables-with-bulk-actions-1map</link>
      <guid>https://dev.to/137foundry/how-to-build-a-selection-state-manager-for-data-tables-with-bulk-actions-1map</guid>
      <description>&lt;p&gt;If your bulk-select checkboxes live directly inside your table component's local state, you've probably already hit the bug where a user selects a row, changes a filter, and the selection count silently drifts from what's actually checked on screen. This guide walks through building a small, dedicated selection manager that survives pagination, filtering, and sorting, instead of patching the symptom every time a new edge case shows up.&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%2F2x8jnuzzxwlepelpco4g.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%2F2x8jnuzzxwlepelpco4g.jpeg" alt="rows of checkboxes in a data table interface" width="800" height="534"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by Towfiqu barbhuiya on &lt;a href="https://www.pexels.com" rel="noopener noreferrer"&gt;Pexels&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: Model Selection as a Set of IDs, Not Row Positions
&lt;/h2&gt;

&lt;p&gt;The first mistake to avoid is tracking selection by row index or by array position. Once a table re-sorts or a filter changes what's visible, positions shift but the underlying records don't, and a selection keyed on position points at the wrong data almost immediately.&lt;/p&gt;

&lt;p&gt;Instead, track selection as a set of stable record IDs, whatever unique identifier your data already has. A JavaScript &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set" rel="noopener noreferrer"&gt;Set&lt;/a&gt; is the natural structure here: membership checks, additions, and removals are all fast, and there's no ambiguity about what "selected" refers to regardless of how the table is currently displayed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const selectedIds = new Set();
function toggleSelection(id) {
  if (selectedIds.has(id)) selectedIds.delete(id);
  else selectedIds.add(id);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Separate "Select Page" From "Select All Matching Filter"
&lt;/h2&gt;

&lt;p&gt;A single "select all" checkbox is ambiguous the moment your table has more rows than fit on one page. Build two distinct actions instead: one that selects only the rows currently rendered, and one that selects every record matching the active filter, even ones not currently loaded into the client.&lt;/p&gt;

&lt;p&gt;The second action needs to be visually and behaviorally distinct, because it can resolve to a dramatically larger number than what's on screen. Show the resolved count explicitly, "Select all 4,213 records matching this filter," rather than letting users assume "select all" only means the twenty rows visible on the current page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Keep Selection State Independent of the Table's Render Cycle
&lt;/h2&gt;

&lt;p&gt;If &lt;a href="https://react.dev" rel="noopener noreferrer"&gt;React&lt;/a&gt; or your framework of choice re-renders the table component on every filter or sort change, and your selection state lives inside that same component, you risk resetting selection unintentionally on re-render, or fighting the framework to preserve it. Lifting selection state into a separate store, a small context provider, a state management library, or a dedicated hook, keeps it stable independent of how often the table itself re-renders.&lt;/p&gt;

&lt;p&gt;This separation also makes selection easier to test in isolation. A selection manager that exposes &lt;code&gt;select(id)&lt;/code&gt;, &lt;code&gt;deselect(id)&lt;/code&gt;, &lt;code&gt;isSelected(id)&lt;/code&gt;, and &lt;code&gt;clear()&lt;/code&gt; as pure functions against a Set can be unit tested without rendering any UI at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Handle Filter and Sort Changes Explicitly
&lt;/h2&gt;

&lt;p&gt;When a filter changes, decide deliberately what happens to the existing selection rather than letting it happen by accident. Two reasonable choices exist: clear the selection entirely when the filter changes, which is safest but can frustrate users mid-workflow, or preserve selected IDs that still match the new filter while dropping ones that don't, with a visible notice telling the user their selection changed.&lt;/p&gt;

&lt;p&gt;Sorting is simpler: since selection is keyed by ID rather than position, a sort change shouldn't affect selection at all as long as your selection manager was built correctly in step 1. If sorting does break your selection, that's usually a sign selection state leaked back into position-based logic somewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Surface the Selection Count Accessibly
&lt;/h2&gt;

&lt;p&gt;The running count of selected items needs to be visible to sighted users and announced to assistive technology when it changes. Tables built without this in mind often render the count as plain text that updates visually but never triggers a screen reader announcement, which quietly excludes keyboard and screen-reader users from a feature aimed at people doing high-volume, repetitive work.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.w3.org/WAI/" rel="noopener noreferrer"&gt;W3C's Web Accessibility Initiative&lt;/a&gt; documents the ARIA patterns for accessible grid selection, including how to expose row selection state so assistive technology announces it consistently with what's shown visually. Building this in from the start is considerably easier than retrofitting it once the table component has grown complex.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Build the Bulk Action Bar Off the Same Source of Truth
&lt;/h2&gt;

&lt;p&gt;The toolbar that appears once one or more rows are selected, showing available bulk actions and the current count, should read directly from the same selection manager, not a separately maintained count. Duplicating the count in two places is exactly how the UI and the actual selection drift apart, which is the root cause of most of the "bulk action affected the wrong rows" bugs teams run into.&lt;/p&gt;

&lt;p&gt;If you're building this on top of an existing table library, check whether it already has selection-state primitives you can hook into. &lt;a href="https://tanstack.com" rel="noopener noreferrer"&gt;TanStack Table&lt;/a&gt; ships row-selection state management as a built-in feature, which can save you from reimplementing steps 1 through 4 from scratch if it fits your stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Test the Edge Cases That Actually Cause Incidents
&lt;/h2&gt;

&lt;p&gt;Once the basic selection manager works, test the specific scenarios that cause production incidents: selecting rows, changing a filter, and confirming the selection count updates correctly; selecting "all matching filter," then narrowing the filter further, and confirming the count shrinks accordingly; and selecting rows across two different pages before triggering a bulk action, and confirming all of them, not just the ones on the currently visible page, get included.&lt;/p&gt;

&lt;p&gt;These are the scenarios that don't show up in a quick manual click-through but do show up the first time a real user works through a large dataset across multiple pages, which is exactly when a bulk action is most valuable and most dangerous to get wrong. Writing these as automated tests against the selection manager directly, rather than relying on manual QA to catch them before every release, is worth the setup time given how easy the underlying bugs are to reintroduce during an unrelated refactor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling Selection Across Grouped or Nested Rows
&lt;/h2&gt;

&lt;p&gt;Tables with grouped rows, line items nested under a parent order, for instance, add a wrinkle worth planning for early. Selecting a parent conceptually selects its children, and selecting only some children needs an indeterminate visual state on the parent checkbox. The cleanest way to handle this is to keep the underlying selection set flat, every selectable row is just an ID in the same Set regardless of nesting level, and compute the parent's indeterminate state by checking whether some but not all of its children are present. Storing parent and child selection as separate flags instead of deriving one from the other is a reliable way to end up with the two silently drifting out of sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Fits Into the Bigger Picture
&lt;/h2&gt;

&lt;p&gt;A correct selection manager is the foundation everything else about bulk actions depends on. Confirmation dialogs that state an accurate count, undo that reverses exactly the right records, and audit logs that reflect what was actually selected, all fail quietly if the underlying selection state was wrong to begin with. 137Foundry's longer guide on &lt;a href="https://137foundry.com/articles/bulk-actions-batch-editing-without-losing-data" rel="noopener noreferrer"&gt;designing bulk actions that don't destroy data&lt;/a&gt; covers the rest of that safety net, from confirmation copy to soft deletes to rate limiting the batch execution itself.&lt;/p&gt;

&lt;p&gt;If your team is building out bulk actions across a growing product and wants a second set of eyes on the selection and safety architecture before it ships, that's the kind of scoping work &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry's app development services&lt;/a&gt; regularly get pulled into, usually after the first version has already shipped and started drifting.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Why Client-Side Routing Breaks Crawlability</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Wed, 09 Sep 2026 11:17:25 +0000</pubDate>
      <link>https://dev.to/137foundry/why-client-side-routing-breaks-crawlability-pei</link>
      <guid>https://dev.to/137foundry/why-client-side-routing-breaks-crawlability-pei</guid>
      <description>&lt;p&gt;Single-page applications route between views without a full page reload, updating the URL and swapping content entirely through JavaScript. It's a good pattern for interactive apps, and it's also one of the more reliable ways to accidentally make large parts of a site invisible to search engines, because the entire concept of "a URL that returns content" gets more fragile than it looks.&lt;/p&gt;

&lt;p&gt;The problem isn't client-side routing as a concept. It's a handful of specific implementation choices that assume every visitor, crawlers included, will always arrive at the root of the app and navigate from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Assumption That Breaks
&lt;/h2&gt;

&lt;p&gt;Client-side routing typically works by intercepting navigation, updating the browser's URL via the History API, and rendering the matching view entirely in JavaScript, without a new request to the server. That's fine when a user clicks a link from within the already-loaded app. It becomes a problem the moment something, a crawler, a shared link, a bookmark, requests one of those routes directly.&lt;/p&gt;

&lt;p&gt;If the server doesn't know how to respond to a direct request for &lt;code&gt;/products/some-item&lt;/code&gt; with meaningful content, and instead serves the same generic shell it serves for every route, a crawler arriving at that URL cold gets nothing useful before JavaScript has a chance to run and correct the mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Shows Up in Practice
&lt;/h2&gt;

&lt;p&gt;The most common failure is a server configuration that serves the same static shell for every route, relying entirely on client-side JavaScript to read the URL and render the correct view after the fact. If a crawler's rendering pass completes before that client-side redirect and content swap finishes, or fails to trigger it at all, the crawler indexes the generic shell instead of the actual page content.&lt;/p&gt;

&lt;p&gt;A related pattern shows up with routes that depend on data fetched after the initial render, where the route itself resolves, but the content within it doesn't exist until a subsequent API call completes. If that call is slow, fails silently, or depends on browser-only state like local storage, the rendered version can end up just as empty as the pre-render shell.&lt;/p&gt;

&lt;p&gt;Redirect logic implemented entirely in JavaScript compounds this further. A route that exists only to immediately redirect somewhere else, using client-side navigation rather than an HTTP redirect, requires a crawler to execute that logic correctly to ever reach the destination content, and adds a failure point that a server-level redirect simply wouldn't have.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Confirm Whether This Is Actually Happening
&lt;/h2&gt;

&lt;p&gt;The most direct check is requesting a specific route's URL cold, without navigating from within the app first, and inspecting what the server actually returns before any JavaScript executes. If that response is a generic, content-less shell regardless of which route was requested, the site is fully dependent on client-side rendering succeeding for every single route to have any indexed content at all.&lt;/p&gt;

&lt;p&gt;Comparing that raw response against what &lt;a href="https://developers.google.com/search" rel="noopener noreferrer"&gt;Google Search Central&lt;/a&gt;'s URL Inspection tool shows as the fully rendered version for the same URL tells you whether Google's renderer is successfully bridging that gap or not. A significant difference between the two, especially on routes with unique, valuable content, is worth treating as a priority fix rather than a curiosity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing It Without Rewriting the Whole App
&lt;/h2&gt;

&lt;p&gt;The most complete fix is moving to a framework that supports server-side rendering or static generation for individual routes, so each URL returns meaningful content directly from the server rather than relying entirely on client-side JavaScript to construct it after the fact. Frameworks built on top of &lt;a href="https://react.dev" rel="noopener noreferrer"&gt;React&lt;/a&gt;, including meta-frameworks like &lt;a href="https://nextjs.org" rel="noopener noreferrer"&gt;Next.js&lt;/a&gt;, support this per-route rather than requiring an all-or-nothing migration, which makes a gradual transition realistic for an existing app.&lt;/p&gt;

&lt;p&gt;Where a full framework change isn't feasible in the short term, ensuring that redirects happen at the server or edge level, rather than purely in client-side JavaScript, removes one of the more fragile failure points without touching the rest of the routing architecture. Prioritizing server-rendering for the specific routes that carry the most search-driven traffic, rather than attempting to convert the entire app at once, is usually the more realistic path.&lt;/p&gt;

&lt;p&gt;"Teams treat JavaScript rendering as a browser problem, but Googlebot's renderer is really a second, slower browser with its own queue and its own budget. If you're not regularly checking what it actually sees, you're optimizing for a version of your site that doesn't exist in the index." - Dennis Traina, &lt;a href="https://137foundry.com/services" rel="noopener noreferrer"&gt;founder of 137Foundry&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A Common Edge Case: Hash-Based Routes and Sitemaps
&lt;/h2&gt;

&lt;p&gt;An older but still surprisingly common pattern uses hash fragments, a URL like &lt;code&gt;/#/products/item&lt;/code&gt;, to represent different views within a single-page app. Search engines have generally treated the portion of a URL after a hash fragment as not part of the request sent to the server, which means two URLs that differ only after the hash can be treated as the same page rather than two distinct, individually indexable routes.&lt;/p&gt;

&lt;p&gt;Sites still running this pattern often see it interact badly with sitemaps, since a sitemap listing hash-based URLs is asking a crawler to treat fragments as if they were real, separate paths, which doesn't reliably hold. Moving to real path-based routing using the History API resolves this cleanly and is usually a smaller change than it sounds, since most routing libraries support both modes and switching is often a configuration change rather than a rewrite of the routing logic itself.&lt;/p&gt;

&lt;p&gt;It's worth auditing an existing sitemap specifically for hash-based entries if a site has any history of using this pattern, since a stale sitemap referencing routes that no longer resolve the way it assumes can quietly waste crawl budget on URLs that were never fully indexable in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Means for Internal Linking Too
&lt;/h2&gt;

&lt;p&gt;Client-side routing issues don't just affect whether a crawler can reach content directly, they also affect how link equity flows through internal links built entirely with client-side navigation. If an internal link is implemented purely as a JavaScript click handler rather than a real anchor tag with an &lt;code&gt;href&lt;/code&gt; pointing at the destination URL, a crawler that doesn't execute that handler correctly never discovers the link at all, regardless of how well the destination page itself renders.&lt;/p&gt;

&lt;p&gt;This is worth checking independently of the routing issue described above, since a site can fix its route-level rendering while still burying internal links behind non-standard navigation patterns that a crawler can't follow. Using real anchor tags for internal navigation, even within a framework that intercepts the click for a smoother client-side transition, keeps both users and crawlers able to reach the same destinations through the same underlying markup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the Fix
&lt;/h2&gt;

&lt;p&gt;After making changes, the same cold-request test from earlier is the fastest way to confirm progress: request a specific, previously-empty route directly and check whether the server response now contains real content rather than a generic shell. Following up with a Search Console URL Inspection live test on the same route confirms Google's own renderer agrees with what you're seeing locally.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Web_crawler" rel="noopener noreferrer"&gt;Wikipedia's overview of web crawling&lt;/a&gt; is a useful refresher on how automated crawling fundamentally differs from a real user's browsing session, which is the underlying reason client-side-only routing causes problems that don't show up in normal QA testing. For the fuller diagnostic process behind catching rendering gaps generally, &lt;a href="https://137foundry.com/articles/diagnose-why-googlebot-isnt-rendering-javascript-content" rel="noopener noreferrer"&gt;this guide on JavaScript rendering and SEO&lt;/a&gt; covers the tools and signals in more depth than a single post can.&lt;/p&gt;

&lt;p&gt;Client-side routing and search visibility aren't fundamentally incompatible. They only conflict when every route depends entirely on JavaScript succeeding perfectly, with no server-level fallback for the moment something arrives at that URL cold. &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;https://137foundry.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Build a Rendering Diff Script With Node.js</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Wed, 09 Sep 2026 11:17:24 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-build-a-rendering-diff-script-with-nodejs-104k</link>
      <guid>https://dev.to/137foundry/how-to-build-a-rendering-diff-script-with-nodejs-104k</guid>
      <description>&lt;p&gt;Checking JavaScript SEO rendering by hand, one URL at a time in Search Console, works fine for a handful of pages. It falls apart once you have dozens of templates and want to catch regressions automatically after every deploy. A small Node.js script that fetches raw HTML and fully rendered HTML for the same URL, then diffs the visible text between them, turns that manual process into something you can run in CI.&lt;/p&gt;

&lt;p&gt;This walks through building that script from scratch, step by step, using tools most JavaScript teams already have available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Decide What "Rendered" Means for Your Comparison
&lt;/h2&gt;

&lt;p&gt;Before writing any code, decide what you're actually comparing. Raw HTML is straightforward: it's the literal response body from a plain HTTP request, before any script executes. Rendered HTML needs a browser-like environment to produce, since it requires actually executing JavaScript and waiting for the page to settle.&lt;/p&gt;

&lt;p&gt;For this script, a headless browser running via &lt;a href="https://nodejs.org" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt; is the standard approach: fetch the raw response first with a simple HTTP client, then load the same URL in a headless browser instance, wait for network activity to go idle, and capture the resulting DOM as your rendered version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Fetch the Raw HTML
&lt;/h2&gt;

&lt;p&gt;Start with the simplest half of the comparison. A plain HTTP GET request, using Node's built-in &lt;code&gt;fetch&lt;/code&gt; or a lightweight HTTP client, retrieves the server's initial response exactly as any crawler's first wave would see it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getRawHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;Store this response as-is. It's your baseline for "what a crawler sees before rendering happens," and it should not go through any further processing at this stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Fetch the Rendered HTML
&lt;/h2&gt;

&lt;p&gt;For the rendered version, load the same URL in a headless browser, wait until the page has genuinely finished its client-side work, and pull the resulting DOM as a string. The specific waiting strategy matters here: waiting a fixed number of seconds is fragile, since some pages settle in under a second and others take much longer depending on data fetching.&lt;/p&gt;

&lt;p&gt;A more reliable approach waits for network activity to go idle for a short window, which approximates what a patient renderer would do before giving up. Once that condition is met, serialize the live DOM back into an HTML string for comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Extract Visible Text From Both Versions
&lt;/h2&gt;

&lt;p&gt;Comparing raw markup directly produces a lot of noise, since whitespace, attribute ordering, and script tags differ even between two versions of the same fundamentally-identical page. What actually matters for SEO purposes is the visible text content, so parse both HTML strings and extract just the text nodes a user would actually read, stripping out script and style content, hidden elements, and markup structure.&lt;/p&gt;

&lt;p&gt;This gives you two plain-text strings: one representing what exists before any JavaScript runs, and one representing what exists after the page fully settles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Compute and Report the Diff
&lt;/h2&gt;

&lt;p&gt;With two clean text strings in hand, a simple length and content comparison already tells you a lot. Calculate the character or word count of each, and flag any URL where the rendered version is dramatically larger than the raw version, since that gap is exactly the content that depends entirely on JavaScript executing successfully.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reportGap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rawText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;renderedText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;renderedText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;rawText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gapPercent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;gap&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;renderedText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gapPercent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;gapPercent&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;% of visible text is render-dependent`&lt;/span&gt;&lt;span class="p"&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;p&gt;The exact threshold worth flagging depends on your site, but a page where more than a third or so of its visible text only exists after rendering is worth a closer manual look, especially if that page is meant to rank for specific, content-driven queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Run It Against a Representative URL Set
&lt;/h2&gt;

&lt;p&gt;A diff script is only useful if you run it against the right pages. Pick one URL per major template, product pages, listing pages, article pages, category pages, rather than trying to check every URL on the site. Templates behave consistently within themselves, so a representative sample catches template-level regressions without the overhead of crawling the entire site on every run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Wire It Into Your Deploy Process
&lt;/h2&gt;

&lt;p&gt;The real value of this script comes from running it automatically rather than remembering to run it manually. Adding it as a post-deploy check, even a simple one that just logs a warning rather than blocking a release, means a framework upgrade or a new data-fetching library that quietly increases the rendering gap gets caught within a day instead of a quarter later when rankings have already dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Handle False Positives Before You Trust the Output
&lt;/h2&gt;

&lt;p&gt;Not every large gap between raw and rendered text is actually a problem. Pages with heavy client-side personalization, a logged-in greeting, a recommendation widget seeded from cookies, will naturally show a bigger gap without necessarily hiding content that matters for search. Filtering these known, expected differences out of your reporting keeps the script's warnings meaningful instead of noisy enough that people start ignoring them.&lt;/p&gt;

&lt;p&gt;A practical way to handle this is maintaining a short allowlist of selectors or content regions known to be legitimately personalized or non-indexable, and excluding them from the text extraction step before computing the gap. Anything outside that allowlist that still shows a large gap is a much stronger signal of a genuine problem worth investigating, rather than an expected side effect of a feature working as designed.&lt;/p&gt;

&lt;p&gt;It's also worth logging the actual missing text snippets, not just a percentage, the first few times you run this against a new template. Skimming what specifically didn't render often reveals the root cause immediately, a specific component, a specific data dependency, faster than trying to reason about it from a bare number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the Script Maintainable Over Time
&lt;/h2&gt;

&lt;p&gt;A diff script that nobody updates eventually drifts out of sync with the site it's checking, especially as new templates get added or existing ones get restructured. Treat the list of representative URLs it checks as something that needs periodic review, roughly every quarter, or any time a new page template ships, rather than a fixed list set once and forgotten.&lt;/p&gt;

&lt;p&gt;It's also worth versioning the thresholds you flag on, since a threshold that made sense for a mostly-static site can be too sensitive once a team intentionally adopts more client-side personalization for legitimate reasons. Revisiting the threshold occasionally, alongside a spot check against what URL Inspection reports, keeps the script's warnings trustworthy rather than something the team learns to tune out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Go From Here
&lt;/h2&gt;

&lt;p&gt;This script gives you a repeatable, code-based signal for rendering health, but it's a complement to spot-checking with &lt;a href="https://developers.google.com/search" rel="noopener noreferrer"&gt;Google Search Central&lt;/a&gt;'s own URL Inspection tool, not a replacement for it. Search Console shows you what Google's actual renderer produced; your own diff script shows you trends across many pages over time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://web.dev" rel="noopener noreferrer"&gt;web.dev&lt;/a&gt; has additional guidance on rendering strategies worth reading once you've identified which templates are carrying the largest gaps, and &lt;a href="https://developer.mozilla.org" rel="noopener noreferrer"&gt;MDN's documentation&lt;/a&gt; is useful for understanding exactly how the DOM gets constructed in whatever headless environment you choose for the rendered fetch.&lt;/p&gt;

&lt;p&gt;"Teams treat JavaScript rendering as a browser problem, but Googlebot's renderer is really a second, slower browser with its own queue and its own budget. If you're not regularly checking what it actually sees, you're optimizing for a version of your site that doesn't exist in the index." - Dennis Traina, &lt;a href="https://137foundry.com/services" rel="noopener noreferrer"&gt;founder of 137Foundry&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want the fuller background on why this gap exists in the first place and how Google's two-wave indexing process works, &lt;a href="https://137foundry.com/articles/diagnose-why-googlebot-isnt-rendering-javascript-content" rel="noopener noreferrer"&gt;this deep dive on JavaScript rendering and SEO&lt;/a&gt; walks through the mechanics behind the numbers this script produces. The team at &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry&lt;/a&gt; builds checks like this into ongoing technical SEO work rather than treating them as a one-time audit.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why AI Coding Assistants Struggle More With Migrations Than App Code</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Tue, 08 Sep 2026 11:26:58 +0000</pubDate>
      <link>https://dev.to/137foundry/why-ai-coding-assistants-struggle-more-with-migrations-than-app-code-4co4</link>
      <guid>https://dev.to/137foundry/why-ai-coding-assistants-struggle-more-with-migrations-than-app-code-4co4</guid>
      <description>&lt;p&gt;An AI coding assistant that writes solid, well-structured application code can still generate a database migration that's technically valid SQL and operationally dangerous. The gap isn't about the assistant's general competence, it's about what kind of context migrations require that application code mostly doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Application code fails visibly. Migrations fail invisibly, then permanently.
&lt;/h2&gt;

&lt;p&gt;Most application code bugs surface quickly: a test fails, an error gets thrown, a user reports something broken. A migration that locks a table for longer than expected, or drops a column that a still-deployed older version of the application still reads, can fail in ways that don't show up until it's already running against production data, at which point some of the damage isn't cleanly reversible the way a bad application code deploy usually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assistant doesn't know your table's actual size
&lt;/h2&gt;

&lt;p&gt;A migration that's perfectly safe on a table with ten thousand rows can cause a multi-minute lock, and a real outage, on the same operation against a table with forty million rows. An AI assistant generating a migration typically has no visibility into your actual production data volume unless you explicitly tell it, which means it defaults to generating the straightforward version of an operation without knowing whether that version is safe at your actual scale.&lt;/p&gt;

&lt;p&gt;This is fundamentally a context problem, not a competence problem. The assistant isn't making a mistake so much as answering a slightly different question than the one that actually matters: "is this SQL correct" instead of "is this SQL safe to run against this specific table right now." &lt;a href="https://www.postgresql.org/docs/current/explicit-locking.html" rel="noopener noreferrer"&gt;PostgreSQL's own documentation on explicit locking&lt;/a&gt; is worth reading in full if you want to understand exactly which operations acquire which lock levels, since that's the underlying mechanism behind almost every migration-related production incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback isn't automatic, and assistants don't always generate it
&lt;/h2&gt;

&lt;p&gt;A well-written migration includes both an "up" and a "down" path, so a mistake can be reversed cleanly. AI assistants asked to write a migration will sometimes generate only the forward direction, especially for a straightforward-seeming schema change, leaving a gap that only becomes obvious when someone actually needs to roll back and discovers there's no clean path to do it.&lt;/p&gt;

&lt;p&gt;Explicitly requesting a rollback path as part of every migration request, and treating a migration without one as incomplete rather than optional, closes this gap directly rather than relying on the assistant to volunteer it unprompted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concurrent deploys complicate migrations in ways application code doesn't face
&lt;/h2&gt;

&lt;p&gt;During a rolling deploy, old and new versions of an application can run simultaneously against the same database for a period of minutes. A migration that changes a column's type or removes a column entirely can break the still-running old version, even if the new version handles the change correctly. This is a genuinely subtle constraint that requires understanding your specific deployment process, not just the schema change in isolation, and it's exactly the kind of cross-cutting context an AI assistant working from a single migration file doesn't have visibility into.&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%2Fqpy8i3rh7k5jw6ydbbgw.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%2Fqpy8i3rh7k5jw6ydbbgw.jpeg" alt="a chalkboard covered in handwritten formulas and diagrams" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by Vitaly Gariev on &lt;a href="https://www.pexels.com" rel="noopener noreferrer"&gt;Pexels&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Index changes are a quiet source of production incidents
&lt;/h2&gt;

&lt;p&gt;Adding an index sounds like a purely additive, low-risk change, and on a small table it usually is. On a large table, building an index can lock writes for an extended period unless it's built with a non-blocking option specific to your database engine, like the &lt;code&gt;CONCURRENTLY&lt;/code&gt; option documented in &lt;a href="https://www.postgresql.org/docs/current/sql-createindex.html" rel="noopener noreferrer"&gt;PostgreSQL's guide to building indexes&lt;/a&gt;. An AI assistant that generates a straightforward &lt;code&gt;CREATE INDEX&lt;/code&gt; statement without the concurrent-safe variant isn't wrong about the SQL syntax, it's just missing the operational context about what happens when that statement runs against a large, actively written table.&lt;/p&gt;

&lt;p&gt;MySQL and MariaDB have their own equivalent mechanisms for building indexes and running schema changes without holding a full table lock, covered in &lt;a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl.html" rel="noopener noreferrer"&gt;MySQL's documentation on online DDL&lt;/a&gt;, and the same principle applies regardless of which relational database you're running: the naive version of a schema change and the production-safe version are often different statements entirely, not just a matter of timing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually closes this gap
&lt;/h2&gt;

&lt;p&gt;The fix isn't avoiding AI assistance for migrations entirely, since a lot of migration work, especially for smaller tables and routine schema changes, is exactly the kind of task an assistant handles well. The fix is providing the missing context explicitly: table size, whether the table is actively written to during business hours, your deployment model, and an explicit requirement for a rollback path on every migration, stated once in your project instructions rather than repeated on every request.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Migrations are where I've seen the biggest gap between how confident an AI-generated suggestion looks and how much operational context is actually missing from it. The SQL is rarely wrong. The assumptions about your specific table and your specific deploy process are where it goes sideways." - 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;
  
  
  Automated checks catch what context alone doesn't
&lt;/h2&gt;

&lt;p&gt;Beyond providing better context upfront, an automated check that flags any migration touching a table above a certain row-count threshold, or any migration missing a corresponding rollback file, catches the cases where context still wasn't enough. This kind of check doesn't require understanding the migration's actual purpose, just applying a mechanical rule that happens to correlate strongly with the operations that cause real incidents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treating migrations as their own review category
&lt;/h2&gt;

&lt;p&gt;Migrations deserve a distinct review posture from typical application code changes, regardless of whether they were AI-assisted or hand-written, precisely because the failure mode is harder to catch in a normal code review and harder to undo once it's shipped. The fuller breakdown of what a layered guardrail system looks like across migrations, authentication, and billing code together is in &lt;a href="https://137foundry.com/articles/guardrails-so-an-ai-coding-assistant-cant-touch-auth-billing-migrations" rel="noopener noreferrer"&gt;137Foundry's guide to setting up AI coding assistant guardrails&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the process right before it becomes a real incident
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry&lt;/a&gt; works with engineering teams to set up exactly this kind of migration-specific review process, from the automated checks that catch dangerous operations before they run to the context templates that give an AI assistant what it actually needs to generate a genuinely production-safe migration on the first attempt rather than the second, post-incident one.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical starting checklist for any AI-assisted migration
&lt;/h2&gt;

&lt;p&gt;Before merging any AI-generated migration, four quick questions catch most of the risk: does it include a working rollback path, does it touch a table above your team's defined size threshold, does it run during a window when concurrent old and new application versions might both be active, and does it use the non-blocking variant of any index or column-type operation where one exists. None of these require deep database expertise to check, they just require remembering to ask them every time rather than only after a migration has already caused a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this gap tends to close naturally over time
&lt;/h2&gt;

&lt;p&gt;Teams that adopt AI coding assistants for migration work usually go through a learning curve: the first few incidents or near-misses teach the team which context to provide upfront, and after that the failure rate on migrations drops substantially, not because the assistant got smarter, but because the team got better at supplying the operational context the assistant needed all along. Front-loading that context deliberately, rather than learning it incident by incident, is the whole point of treating migrations as their own reviewed category from the start.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>automation</category>
      <category>programming</category>
      <category>database</category>
    </item>
    <item>
      <title>How to Set Up a Pre-Commit Hook That Catches Hardcoded Secrets</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Tue, 08 Sep 2026 11:26:57 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-set-up-a-pre-commit-hook-that-catches-hardcoded-secrets-3fci</link>
      <guid>https://dev.to/137foundry/how-to-set-up-a-pre-commit-hook-that-catches-hardcoded-secrets-3fci</guid>
      <description>&lt;p&gt;A hardcoded API key or a committed .env file is one of the oldest security mistakes in software, and it hasn't gone away just because tooling has improved. If anything, AI coding assistants trained on public repositories sometimes reproduce patterns, like a placeholder key formatted just like a real one, or a config example that looks like it should be gitignored but isn't, that make this specific mistake slightly more likely to slip through. Here's a step-by-step way to catch it before it ever reaches a commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Pick a secret-scanning tool that runs locally
&lt;/h2&gt;

&lt;p&gt;Several well-maintained tools scan a diff for patterns that look like credentials, API keys, and tokens before a commit completes. Widely used open-source options like &lt;a href="https://github.com/gitleaks/gitleaks" rel="noopener noreferrer"&gt;Gitleaks&lt;/a&gt; and &lt;a href="https://github.com/Yelp/detect-secrets" rel="noopener noreferrer"&gt;detect-secrets&lt;/a&gt; both support running as a local pre-commit hook, rather than relying solely on a server-side scan after the push already happened, catching the mistake at the earliest possible point, before the secret has left your machine at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Install it as an actual git hook, not just a CI step
&lt;/h2&gt;

&lt;p&gt;A secret scanner running only in CI still means the secret was committed and pushed before anyone caught it, which is a meaningfully worse outcome than catching it locally, since a pushed secret needs to be rotated even after the offending commit is removed from history. Configuring the scanner through a framework like &lt;a href="https://pre-commit.com/" rel="noopener noreferrer"&gt;pre-commit&lt;/a&gt;, which makes hook installation consistent and shareable across a whole team, closes this gap at the source rather than depending on each contributor to remember to set it up manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Tune the pattern list to your actual providers
&lt;/h2&gt;

&lt;p&gt;Off-the-shelf secret scanners ship with pattern detection for common providers like cloud platforms and popular APIs, but your team likely uses at least a few services the default pattern list doesn't cover. Spend the time to add custom patterns for your specific internal services and less common third-party providers, since a scanner that only catches the well-known formats gives a false sense of complete coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Set the hook to fail loudly, not warn quietly
&lt;/h2&gt;

&lt;p&gt;A pre-commit hook that only prints a warning and lets the commit proceed anyway is easy to ignore under deadline pressure, which defeats the purpose. Configure the hook to actually block the commit when it detects a likely secret, requiring an explicit override flag if a match turns out to be a false positive, so the default behavior is safe rather than permissive.&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%2Fqhg2sun3m80t5ormsk7c.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%2Fqhg2sun3m80t5ormsk7c.jpeg" alt="a chalkboard covered in handwritten formulas and diagrams" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Photo by Vitaly Gariev on &lt;a href="https://www.pexels.com" rel="noopener noreferrer"&gt;Pexels&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Add a corresponding CI-level check as a backstop
&lt;/h2&gt;

&lt;p&gt;Not every contributor will have the local hook installed correctly, especially on a larger team or with external contributors, so a server-side scan in CI as a second layer catches anything that slipped past a missing or misconfigured local hook. This isn't redundant, it's defense in depth against the specific failure mode of a hook that exists but isn't actually running for one contributor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Handle historical secrets separately from new ones
&lt;/h2&gt;

&lt;p&gt;A pre-commit hook only protects future commits. If your repository's history already contains committed secrets, those need to be rotated and, depending on your risk tolerance, purged from git history using a dedicated history-rewriting tool like &lt;a href="https://github.com/newren/git-filter-repo" rel="noopener noreferrer"&gt;git filter-repo&lt;/a&gt;. Running a one-time full-history scan when you first set up the hook surfaces this gap rather than leaving it undiscovered, and the &lt;a href="https://owasp.org/www-project-top-ten/" rel="noopener noreferrer"&gt;OWASP guidance on secrets management&lt;/a&gt; is a reasonable reference for how credential handling fits into a broader application security posture beyond just this one control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Document the override process clearly
&lt;/h2&gt;

&lt;p&gt;False positives happen, particularly with pattern-based detection catching something that looks like a key but isn't. Document a clear, quick process for reviewing and overriding a false positive, since a hook that's frequently annoying to work around gets disabled entirely by a frustrated team faster than one with a smooth, fast override path.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The teams that get burned by a leaked key almost never had zero protection. They had a scanner that existed on paper but wasn't actually blocking the commit, or wasn't installed on the one contributor's machine where the leak happened." - 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;
  
  
  Step 8: Extend the same discipline to configuration files and examples
&lt;/h2&gt;

&lt;p&gt;Example configuration files, meant to show the shape of a config without real values, are a common place for a placeholder that looks close enough to a real credential to trip a scanner, or worse, to actually contain a real value that was meant to be replaced before committing. Treating example config files with the same scrutiny as application code, rather than assuming they're inherently safe, closes a specific gap AI-generated example configs sometimes introduce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more now, not less
&lt;/h2&gt;

&lt;p&gt;It's tempting to assume secret-scanning tooling is a solved problem that doesn't need active attention. The specific risk from AI-assisted development is subtle: an assistant generating a plausible-looking code example, config snippet, or test fixture occasionally reproduces a pattern that looks enough like a real credential format to be genuinely risky if a placeholder value happens to resemble an actual key from a real account. A properly configured pre-commit hook closes this gap regardless of whether the code came from a human or an assistant.&lt;/p&gt;

&lt;p&gt;For the broader picture of where secrets and credential handling fit alongside authentication, billing, and migrations as high-risk categories worth explicit guardrails, &lt;a href="https://137foundry.com/articles/guardrails-so-an-ai-coding-assistant-cant-touch-auth-billing-migrations" rel="noopener noreferrer"&gt;137Foundry's guide on setting up AI coding assistant guardrails&lt;/a&gt; covers the full picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting it set up right the first time
&lt;/h2&gt;

&lt;p&gt;Setting up secret scanning correctly, with the right pattern coverage for your specific stack and a process the team actually follows, is a small investment that prevents a genuinely expensive class of incident. &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;https://137foundry.com&lt;/a&gt; works with engineering teams on exactly this kind of practical security tooling, tuned to the specific providers and workflows a team actually uses rather than a generic default configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do the moment a real secret does leak
&lt;/h2&gt;

&lt;p&gt;Even with a well-configured hook, mistakes happen, someone commits from a machine without the hook installed, or force-pushes around it under deadline pressure. Having a documented incident response for this specific scenario matters as much as the prevention tooling itself: rotate the credential immediately, treat the old value as compromised regardless of whether you can confirm it was actually used maliciously, and only then worry about cleaning the leaked value out of git history. Rotation first, history cleanup second, is the order that actually protects you, since a leaked credential is exploitable the moment it's pushed, independent of whether it still exists somewhere in your commit history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring whether the setup is actually working
&lt;/h2&gt;

&lt;p&gt;A secret-scanning setup that's never triggered a false positive in six months of active development is worth a second look, since it either means your team writes exceptionally careful code or, more likely, means the scanner's coverage has a gap nobody's noticed yet. Periodically testing the hook with a deliberately fake credential pattern, then confirming it gets caught, is a cheap way to verify the tooling is actually still working rather than silently failing to run.&lt;/p&gt;

</description>
      <category>security</category>
      <category>git</category>
      <category>automation</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Write a Permission Test Matrix That Actually Catches Regressions</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Mon, 07 Sep 2026 11:32:24 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-write-a-permission-test-matrix-that-actually-catches-regressions-j29</link>
      <guid>https://dev.to/137foundry/how-to-write-a-permission-test-matrix-that-actually-catches-regressions-j29</guid>
      <description>&lt;p&gt;Most permission bugs that reach production don't fail loudly. Over-permissive bugs leak data quietly until someone notices something they shouldn't have seen. Under-permissive bugs generate support tickets that rarely get traced back to the actual missing check. A test suite that only covers the happy path catches neither. Here's how to build one that does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: List Every Role and Every Protected Action Separately
&lt;/h2&gt;

&lt;p&gt;Before writing a single test, make two flat lists: every role in your system, and every action your &lt;code&gt;can()&lt;/code&gt; function gates. Don't try to write the tests yet. This step alone often surfaces gaps, like an action nobody remembered to add a role for, or a role that has no tests referencing it at all.&lt;/p&gt;

&lt;p&gt;Keep the lists in a plain text file or spreadsheet next to your test suite, not buried in code comments. It should be trivially easy for a reviewer to check "is there a test for this role against this action" without reading test implementations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Build the Matrix, Not a List
&lt;/h2&gt;

&lt;p&gt;The mistake most teams make is writing tests as a list: "admin can delete," "editor can edit," "viewer can view." That covers the cases someone thought to write down, and misses everything else. A matrix tests every role against every action explicitly, asserting both the allow and the deny case.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ROLES&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;permission matrix for %s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ACTIONS&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;action: %s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EXPECTED_MATRIX&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&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;p&gt;The &lt;code&gt;EXPECTED_MATRIX&lt;/code&gt; constant becomes your source of truth, readable at a glance, and it's the artifact a reviewer checks against the product requirement rather than reading through dozens of individual assertions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Make the Deny Case a First-Class Assertion
&lt;/h2&gt;

&lt;p&gt;It's tempting to only test that a role can do what it's supposed to. The denial case is exactly what regresses silently when someone adds a new permission and forgets to gate a new endpoint behind it. If your matrix only asserts allows, a new action added without a corresponding deny assertion for every other role will pass every existing test while being wide open by default.&lt;/p&gt;

&lt;p&gt;Assert both directions for every cell in the matrix, even when it feels redundant. The redundant-feeling assertions are the ones that catch the bug eighteen months from now when nobody remembers the original design intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Treat a Missing Test as a Blocked PR, Not a Follow-Up Ticket
&lt;/h2&gt;

&lt;p&gt;The cost of writing a permission test at review time is minutes. The cost of discovering a missing one is usually a support escalation and an uncomfortable conversation about what a customer's contractor could see that they shouldn't have. Any new mutating endpoint without an entry in the test matrix should block the pull request, the same way a missing migration would.&lt;/p&gt;

&lt;p&gt;This is a policy decision, not just a technical one, and it's worth stating explicitly in your contribution guidelines rather than leaving it to reviewer memory. &lt;a href="https://cheatsheetseries.owasp.org/" rel="noopener noreferrer"&gt;OWASP's testing guidance&lt;/a&gt; treats access control testing the same way, as a required gate rather than an optional nice-to-have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Test Scoped Permissions With Real Resource Boundaries
&lt;/h2&gt;

&lt;p&gt;If your system scopes permissions to teams, tenants, or specific resources, the matrix needs a second dimension beyond role and action: does this permission check correctly fail when the resource belongs to a different scope than the user's? A role that can approve invoices for their own team but not another team's is a different assertion than the basic role/action pair, and it's the specific case that tends to regress when caching or scope-resolution logic changes.&lt;/p&gt;

&lt;p&gt;Build fixtures with at least two of every scoped entity (two teams, two tenants) specifically so tests can assert the cross-scope denial explicitly, rather than only testing within a single scope where a missing boundary check would pass silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consider Property-Based Testing for Large Matrices
&lt;/h2&gt;

&lt;p&gt;Once a permission matrix grows past a handful of roles and actions, hand-writing every cell becomes tedious and error-prone in its own right. Property-based testing tools like &lt;a href="https://hypothesis.works/" rel="noopener noreferrer"&gt;Hypothesis&lt;/a&gt; can generate combinations of roles, actions, and scopes automatically and assert a general property, such as "a user never has an action their role doesn't include," rather than requiring every cell to be listed by hand.&lt;/p&gt;

&lt;p&gt;This doesn't replace the explicit matrix for your core roles, which is still the clearest documentation of intended behavior. It's a good complement for catching combinations nobody thought to write down explicitly, especially once scoped permissions add a third dimension that makes manual enumeration genuinely tedious.&lt;/p&gt;

&lt;h2&gt;
  
  
  If You're Layering in Policy-Based Rules
&lt;/h2&gt;

&lt;p&gt;Teams that eventually add conditional or attribute-based rules on top of role-based access control, using something like &lt;a href="https://www.openpolicyagent.org/" rel="noopener noreferrer"&gt;Open Policy Agent&lt;/a&gt; or &lt;a href="https://casbin.org/" rel="noopener noreferrer"&gt;Casbin&lt;/a&gt;, still need the same matrix discipline, just extended to cover the conditions rather than only the base role/action pairs. A policy engine doesn't remove the need for explicit allow and deny test cases. It just means the matrix now needs entries for "role X with attribute Y should be denied," which is easy to forget if the test suite was built before those rules existed.&lt;/p&gt;

&lt;p&gt;Whichever model you're testing, the underlying principle stays the same: every combination that can grant or deny access needs an explicit, automated assertion, because the ones nobody thought to write down are exactly the ones that regress silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the Matrix Current as the Product Changes
&lt;/h2&gt;

&lt;p&gt;A permission test matrix is only useful while it stays accurate, and products change fast enough that an unmaintained matrix drifts out of date within a few months. Make updating the matrix a required step whenever a new role or action is introduced, the same way updating a database migration is a required step when the schema changes, not an optional cleanup task someone gets to eventually.&lt;/p&gt;

&lt;p&gt;One practical habit: have the matrix file itself fail a lint check if a new permission string appears in the codebase without a corresponding entry, so the gap surfaces at PR time rather than being caught later during an unrelated bug investigation. This turns "did anyone remember to add a test" into an automated question instead of a hope, which is exactly the kind of guarantee permission logic benefits from most.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Fits Into the Bigger Picture
&lt;/h2&gt;

&lt;p&gt;A test matrix is only as good as the permission model it's testing. If roles and actions are still scattered across ad-hoc boolean flags rather than a real permissions table, there's no clean place to enumerate the matrix from in the first place. &lt;a href="https://137foundry.com/articles/role-based-permissions-system-that-scales" rel="noopener noreferrer"&gt;Our guide on building a role-based permissions system&lt;/a&gt; covers the data model this testing approach assumes, including how scoping and caching interact with the checks you're testing here.&lt;/p&gt;

&lt;p&gt;137Foundry has built this exact matrix pattern into CI for several client codebases, and the return on investment tends to show up the first time someone adds a feature under deadline pressure and the test suite catches the missing deny case before it ships.&lt;/p&gt;

&lt;p&gt;That moment, a red test blocking a merge instead of a support ticket three weeks later, is the entire argument for building the matrix in the first place. It's a small amount of upfront structure that turns a category of bug that used to be nearly invisible into one CI catches automatically, every time, without relying on anyone remembering to check by hand.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why Permission Checks Belong in One Function, Not Scattered Across Controllers</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Mon, 07 Sep 2026 11:32:23 +0000</pubDate>
      <link>https://dev.to/137foundry/why-permission-checks-belong-in-one-function-not-scattered-across-controllers-4f22</link>
      <guid>https://dev.to/137foundry/why-permission-checks-belong-in-one-function-not-scattered-across-controllers-4f22</guid>
      <description>&lt;p&gt;Grep any codebase old enough to have real customers for &lt;code&gt;role ===&lt;/code&gt; or &lt;code&gt;.includes('admin')&lt;/code&gt; and you'll usually find a lot of hits. Each one made sense the day it was written. Together they mean nobody can answer "who can currently delete a customer record" without reading every controller, middleware, and background job in the system. That question needs a single, findable answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Permission Logic That's Everywhere
&lt;/h2&gt;

&lt;p&gt;Scattered checks drift apart over time in ways that are easy to miss individually. One controller checks &lt;code&gt;user.role === 'admin'&lt;/code&gt;, another checks &lt;code&gt;user.role === 'admin' || user.role === 'owner'&lt;/code&gt;, and a background job that runs the same logical operation forgot the &lt;code&gt;owner&lt;/code&gt; case entirely. Nobody decided this should be inconsistent. It happened because there was no single place enforcing consistency in the first place.&lt;/p&gt;

&lt;p&gt;The fix isn't a style guide asking developers to remember to check both roles everywhere. It's removing the ability to write the check any other way, by making the permission function the only path to the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Centralized Check Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;The shape is simple: one function, something like &lt;code&gt;can(user, action, resource)&lt;/code&gt;, that every mutating endpoint and background job calls before doing anything. It's the only piece of code that queries roles or permissions tables directly. Controllers, jobs, and API middleware call it and get back true or false. They never reconstruct the logic themselves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolvePermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&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;That resolvePermissions call is where roles get expanded into an actual permission set, cached per request, and scoped to whatever resource or tenant is relevant. Everything downstream of it is a boolean check, which is exactly the kind of code that's easy to review and easy to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Also Fixes Your Test Coverage
&lt;/h2&gt;

&lt;p&gt;When permission logic is centralized, you can write one comprehensive test suite against the &lt;code&gt;can&lt;/code&gt; function covering every role and every action, instead of trying to test the same logic reimplemented slightly differently in a dozen controllers. A single well-tested function is far more trustworthy than fifty untested inline conditionals that each look reasonable on their own.&lt;/p&gt;

&lt;p&gt;It also makes code review meaningfully easier. A reviewer only needs to check "does this endpoint call &lt;code&gt;can()&lt;/code&gt; with the right action" rather than re-verifying an entire access control expression written inline. That's a much smaller cognitive load, and it catches missing checks faster because a missing &lt;code&gt;can()&lt;/code&gt; call stands out in a diff in a way a subtly wrong inline condition doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling the Edge Cases Without Breaking the Pattern
&lt;/h2&gt;

&lt;p&gt;Real systems have exceptions: a support tool that needs impersonation, a system account that bypasses normal scoping, a legacy integration with its own auth path. The temptation is to special-case these outside the centralized function. Resist it. Model them as explicit permissions or a distinct user type that flows through the same &lt;code&gt;can()&lt;/code&gt; call, even if the underlying resolution logic differs.&lt;/p&gt;

&lt;p&gt;This matters because the moment you allow a second legitimate path to bypass the central check, you've reopened the door to the scattered-logic problem you were trying to close. &lt;a href="https://cheatsheetseries.owasp.org/" rel="noopener noreferrer"&gt;OWASP's access control guidance&lt;/a&gt; makes the same point from a security angle: every additional code path that can grant access is another place a review has to look, and every one you can eliminate is a real reduction in risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  This Isn't a New Idea, Just a Neglected One
&lt;/h2&gt;

&lt;p&gt;Centralizing access decisions is exactly what role-based access control was formalized to do in the first place, long before most current web frameworks existed. &lt;a href="https://en.wikipedia.org/wiki/Role-based_access_control" rel="noopener noreferrer"&gt;Wikipedia's overview of role-based access control&lt;/a&gt; lays out the same three-part separation this piece has been describing: users, roles, and permissions, kept distinct so any one of them can change without touching the other two. Scattered inline checks aren't a different architecture. They're the same underlying problem the formal model exists to solve, just implemented without the structure that makes it maintainable.&lt;/p&gt;

&lt;p&gt;Hosted identity platforms lean on the same principle for the same reason. &lt;a href="https://auth0.com/" rel="noopener noreferrer"&gt;Auth0&lt;/a&gt; and comparable providers ship centralized role and permission management as a core feature, not an afterthought, because "one function decides access, everything else asks it" turns out to be the pattern nearly every team converges on eventually, whether they build it themselves or buy it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Caching Question Nobody Asks Until It's a Problem
&lt;/h2&gt;

&lt;p&gt;Once you centralize permission resolution, the next question is almost always performance, because that one function now runs on nearly every request instead of being scattered across a handful of endpoints. Resist the urge to skip caching entirely just to keep the implementation simple. A permission resolution that hits the database on every request works fine in a demo and falls over under real traffic.&lt;/p&gt;

&lt;p&gt;Cache the resolved permission set per user, scoped by whatever resource context matters, with a short TTL and an explicit invalidation hook fired on role or membership changes. That combination handles the overwhelming majority of cases correctly, and it's a much smaller amount of code than most teams expect once the resolution logic itself is already centralized in one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Do When Frameworks Fight You On This
&lt;/h2&gt;

&lt;p&gt;Some frameworks encourage scattering authorization logic by design, with decorators or middleware attached directly to route definitions rather than a single shared function. That pattern isn't inherently wrong, but it's easy to let it drift into inconsistency if each route's decorator reimplements the check slightly differently instead of all calling the same underlying function.&lt;/p&gt;

&lt;p&gt;The fix isn't abandoning the framework's conventions. It's making sure every decorator or middleware is a thin wrapper around the same centralized &lt;code&gt;can()&lt;/code&gt; call, never an independent reimplementation. Whatever mechanism your framework prefers for attaching authorization to a route, treat it purely as plumbing that delegates to one shared decision point, the same principle &lt;a href="https://developer.mozilla.org/" rel="noopener noreferrer"&gt;MDN's documentation on web security&lt;/a&gt; applies broadly across authentication and authorization concerns regardless of framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to Start on an Existing Codebase
&lt;/h2&gt;

&lt;p&gt;You don't need a rewrite. Grep for the scattered patterns first, build the centralized &lt;code&gt;can()&lt;/code&gt; function alongside them, and migrate one controller at a time to call it instead of its inline check. Keep the old checks working as a fallback until you're confident the new function covers every case the old logic did.&lt;/p&gt;

&lt;p&gt;If you're designing the underlying roles and permissions data model from scratch rather than retrofitting one, &lt;a href="https://137foundry.com/articles/role-based-permissions-system-that-scales" rel="noopener noreferrer"&gt;our full guide on building a role-based permissions system&lt;/a&gt; walks through the schema, scoping, and caching strategy that this centralized function sits on top of. &lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;137Foundry&lt;/a&gt; has done this migration on production systems where the scattered checks had been accumulating for years, and it's almost always more tractable than it looks from the outside once you have one function to migrate toward.&lt;/p&gt;

&lt;p&gt;The goal isn't perfection on day one. It's making sure there's exactly one place where "can this user do this" gets decided, so that answer stays trustworthy as the system grows.&lt;/p&gt;

&lt;p&gt;That single place is also the piece of the codebase most worth over-investing in relative to its size. It's rarely more than a couple hundred lines, but every feature your product ships that touches access control runs through it, which makes it one of the highest-leverage places to put careful review, thorough tests, and clear naming.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Log Timestamps So Support Tickets Don't Become Timezone Detective Work</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Sun, 06 Sep 2026 11:28:27 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-log-timestamps-so-support-tickets-dont-become-timezone-detective-work-3kl7</link>
      <guid>https://dev.to/137foundry/how-to-log-timestamps-so-support-tickets-dont-become-timezone-detective-work-3kl7</guid>
      <description>&lt;p&gt;A customer reports "the export failed around 3 PM my time yesterday." The support engineer now has to figure out what timezone the customer is in, convert that to server time, and search logs across a window wide enough to account for the ambiguity in "around." This entire investigation is avoidable with better logging practices from the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Problem: Logs Without Explicit Timezone Context
&lt;/h2&gt;

&lt;p&gt;Log lines that print a bare timestamp without an explicit UTC marker or offset force every reader to guess. &lt;code&gt;2026-09-06 15:42:03&lt;/code&gt; could be server local time, the deploying engineer's laptop timezone if logs were generated in a dev environment, or already UTC, and nothing in the line itself tells you which.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Ambiguous: what timezone is this?
2026-09-06 15:42:03 ERROR export failed for user 4471

// Unambiguous: explicit UTC marker, ISO 8601 format
2026-09-06T15:42:03.000Z ERROR export failed for user 4471
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Standardizing every log line, across every service, on ISO 8601 with an explicit &lt;code&gt;Z&lt;/code&gt; suffix removes this ambiguity permanently, and it costs nothing beyond a one-time logging configuration change. The &lt;a href="https://en.wikipedia.org/wiki/ISO_8601" rel="noopener noreferrer"&gt;ISO 8601 standard&lt;/a&gt; is the same format worth standardizing on across API payloads too, so a single convention covers both what your services log and what they transmit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correlate Customer-Reported Times With Their Actual Timezone, Not Yours
&lt;/h2&gt;

&lt;p&gt;When a support ticket references a time, the support tooling should capture or ask for the customer's timezone explicitly, then convert to UTC before searching logs, rather than leaving the support engineer to do that math manually under ticket-response time pressure. Many support platforms can auto-detect a customer's timezone from their account settings or browser locale at signup, which removes a step from every future ticket if it's wired up once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convertCustomerTimeToUTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localTimeStr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerTimezone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Customer said "3 PM yesterday", customer record says America/Chicago&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;localTimeStr&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UTC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dateStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;short&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;timeStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localDate&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;
  
  
  Include the Server's Resolved Timezone Name in Every Deploy's Log Header
&lt;/h2&gt;

&lt;p&gt;Server local time can vary across regions if your infrastructure spans multiple data centers, and a support engineer debugging a distributed system needs to know which timezone each service instance is actually running in, not assume they all match. Logging the resolved IANA timezone name (not just a raw offset, which is ambiguous around DST) once at service startup gives every subsequent log line unambiguous context without repeating it on every line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;startupTimezone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;resolvedOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;timeZone&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;logger&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="s2"&gt;`Service starting, resolved timezone: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;startupTimezone&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Widen Your Search Window Deliberately, Not by Guessing
&lt;/h2&gt;

&lt;p&gt;Even with clean UTC logging, a customer's memory of "around 3 PM" has natural imprecision, and searching a narrow five-minute window around their stated time risks missing the actual event if their memory is off by fifteen or twenty minutes. Building a standard, documented default search window (say, plus or minus 30 minutes) into your support tooling's log search removes the guesswork of how wide to search on every single ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Correlate Across Services Using a Shared Request ID, Not Just Timestamps
&lt;/h2&gt;

&lt;p&gt;For any request that touches multiple services, a shared correlation ID passed through every service's logs is far more reliable for tracing an incident than trying to line up timestamps across services that might have small clock drift relative to each other. Timestamp correlation should be the fallback for finding the initial event, not the primary tool for tracing a request across a distributed system. The &lt;a href="https://opentelemetry.io" rel="noopener noreferrer"&gt;OpenTelemetry project&lt;/a&gt; documents patterns for this kind of distributed tracing in more depth if your system doesn't already have a correlation ID convention in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Train Support Teams on the One Conversion They Actually Need
&lt;/h2&gt;

&lt;p&gt;Support engineers don't need to become timezone experts, they need one reliable, repeatable process: capture the customer's timezone (or look it up from their account), convert their stated time to UTC, search logs with a sensible buffer window. Documenting this exact three-step process in the support team's internal runbook, with a copy-pasteable conversion tool or snippet, turns a recurring investigation into a routine lookup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;Our team&lt;/a&gt; has built support tooling for several clients specifically around this problem, since the cost of ambiguous logging compounds across every single ticket that touches a timestamp, not just the occasional edge case. For the underlying code patterns that prevent timestamp ambiguity from entering your system in the first place, our &lt;a href="https://137foundry.com/articles/date-time-handling-code-snippets-timezone-dst-parsing" rel="noopener noreferrer"&gt;recent piece on date and time code snippets&lt;/a&gt; covers the parsing and storage side of this same problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Log Aggregation Tools Can Reinforce Bad Habits If Misconfigured
&lt;/h2&gt;

&lt;p&gt;Modern log aggregation platforms generally display timestamps in the viewer's own browser timezone by default, which is convenient for a single engineer but can create confusion on a team spread across regions, where two engineers looking at the identical log line in the identical tool see two different displayed times and have to remember to mentally normalize before comparing notes in a shared incident channel. Configuring team dashboards and shared views to display a single agreed-upon timezone, usually UTC, for anything used collaboratively, while leaving personal ad-hoc queries in the engineer's own local time, avoids a surprising amount of confusion during live incident response, when the extra cognitive overhead of a timezone conversion is the last thing anyone has spare attention for. Incident response guidance from groups like &lt;a href="https://response.pagerduty.com" rel="noopener noreferrer"&gt;PagerDuty's incident response documentation&lt;/a&gt; generally recommends standardizing on UTC for any shared incident timeline for exactly this reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retroactively Fixing Ambiguous Historical Logs Is Rarely Worth It
&lt;/h2&gt;

&lt;p&gt;Once a team recognizes their logs have been using ambiguous local timestamps, the instinct is sometimes to try to retroactively reconstruct which timezone historical entries were actually logged in and backfill a corrected UTC value. In practice this is rarely worth the effort unless there's a specific compliance or legal reason to have precise historical timestamps, since the reconstruction itself introduces its own uncertainty (was the server's system timezone ever changed during that period? did a deploy briefly run on a misconfigured host?). The better use of that effort is almost always fixing the logging going forward and accepting that historical incident investigations from before the fix will carry some inherent ambiguity.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Simple Convention Beats a Complex Policy Nobody Follows
&lt;/h2&gt;

&lt;p&gt;Teams sometimes overcorrect on this problem by writing an elaborate internal logging policy document covering every edge case, which then goes unread and unenforced because it's too long for anyone to actually internalize during a normal workday. The version of this that actually sticks across a team is short enough to fit in a code review comment template: every timestamp in every log line is UTC, in ISO 8601 format, with an explicit &lt;code&gt;Z&lt;/code&gt;, full stop. Anything more nuanced than that single rule can live in a linked reference doc for the rare case that needs it, but the rule itself needs to be simple enough that a new engineer absorbs it from their first code review without needing to read a policy document at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling This Out on an Existing Team Without a Big-Bang Migration
&lt;/h2&gt;

&lt;p&gt;For a team with an existing, inconsistent logging setup, the practical rollout path is usually a shared logging wrapper or middleware that enforces the UTC-ISO-8601 format at the point every log call actually gets written, rather than relying on every individual engineer to remember the convention on every call site. Making the correct behavior the path of least resistance, the default the wrapper produces without extra effort, gets far higher compliance than a style guide entry that depends on everyone remembering to follow it consistently across dozens of services and hundreds of call sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;Timestamp ambiguity in logs turns every timezone-related support ticket into an investigation instead of a lookup. Standardize on UTC with explicit ISO 8601 formatting everywhere, capture customer timezone context proactively, log each service's resolved timezone name at startup, use a documented search buffer instead of guessing, and lean on correlation IDs rather than timestamp matching for distributed tracing. None of this is complicated, it just has to be decided once and applied consistently.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How to Debug a Cron Job That Silently Skips Its DST Run</title>
      <dc:creator>137Foundry</dc:creator>
      <pubDate>Sun, 06 Sep 2026 11:28:26 +0000</pubDate>
      <link>https://dev.to/137foundry/how-to-debug-a-cron-job-that-silently-skips-its-dst-run-3fe</link>
      <guid>https://dev.to/137foundry/how-to-debug-a-cron-job-that-silently-skips-its-dst-run-3fe</guid>
      <description>&lt;p&gt;A recurring job scheduled for 2:30 AM local time runs reliably for months, then twice a year it either doesn't run at all or runs twice back to back. Nobody touched the cron config. This is the spring-forward and fall-back gap, and it's a specific, diagnosable failure mode once you know what to look for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Confirm the Job Is Scheduled in Local Time, Not UTC
&lt;/h2&gt;

&lt;p&gt;The first thing to check is whether the scheduler is configured with a timezone-aware local time or a fixed UTC time. Many cron implementations default to the server's system timezone, and if that's a US or EU timezone observing DST, any job scheduled inside the 2:00-3:00 AM window during a spring-forward transition is scheduling itself into a wall-clock hour that doesn't exist that day.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check what timezone cron itself is interpreting schedules in&lt;/span&gt;
timedatectl status
&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/timezone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the job is meant to run at a specific UTC instant regardless of local wall-clock time, switching the schedule to reference UTC directly sidesteps the entire DST ambiguity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Identify Which Transition Type Caused the Symptom
&lt;/h2&gt;

&lt;p&gt;Spring-forward (an hour skips, e.g., 2:00 AM jumps straight to 3:00 AM) and fall-back (an hour repeats, 1:00-2:00 AM happens twice) produce different symptoms. A job scheduled inside the skipped hour during spring-forward typically doesn't fire at all that day, since the wall-clock time it was waiting for never occurred. A job scheduled inside the repeated hour during fall-back can fire twice, once during each occurrence of that hour, if the scheduler isn't explicitly deduplicating.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check your job's execution log around the transition date&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"2026-03-08"&lt;/span&gt; /var/log/cron.log
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"2026-11-01"&lt;/span&gt; /var/log/cron.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cross-referencing the exact date against your region's published DST transition dates (available from the &lt;a href="https://www.iana.org/time-zones" rel="noopener noreferrer"&gt;IANA Time Zone Database&lt;/a&gt;) confirms whether the missing or duplicated run lines up with a transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Check Whether Your Scheduler Documents Its DST Behavior
&lt;/h2&gt;

&lt;p&gt;Not every scheduler handles this the same way, and the "correct" behavior is genuinely ambiguous, there's no universally right answer for what a job scheduled at a now-nonexistent time should do. Some schedulers skip the run entirely for that day, others shift it to the nearest valid time, others fire on the pre-transition UTC equivalent. Reading your specific scheduler's documentation on this exact question, rather than assuming, is a five-minute step that saves hours of confused debugging later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Node-based schedulers (node-cron, node-schedule) generally rely on&lt;/span&gt;
&lt;span class="c1"&gt;// the underlying system timezone database via Intl, but confirm your&lt;/span&gt;
&lt;span class="c1"&gt;// specific library's stated behavior rather than assuming&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cron&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node-cron&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;30 2 * * *&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;runJob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;America/New_York&lt;/span&gt;&lt;span class="dl"&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 4: Add Idempotency to Protect Against the Fall-Back Duplicate Run
&lt;/h2&gt;

&lt;p&gt;Regardless of which specific behavior your scheduler has, the safest fix for the "runs twice" fall-back scenario is making the job itself idempotent, so a duplicate trigger doesn't cause a duplicate side effect. This is a good practice independent of DST entirely, since retries and scheduler quirks can cause duplicate triggers for other reasons too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runIdempotentJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alreadyRan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkJobLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alreadyRan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Job &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; already ran for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, skipping duplicate`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;executeJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordJobLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&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 5: For Business-Critical Jobs, Avoid Scheduling Inside the Transition Window Entirely
&lt;/h2&gt;

&lt;p&gt;The most reliable fix for anything genuinely business-critical, payroll processing, billing runs, data exports with compliance deadlines, is simply avoiding scheduling inside the 1:00-3:00 AM local window in regions that observe DST. Moving a critical job to a time like 5:00 AM local, well outside any transition window, eliminates the entire failure category without needing to reason about scheduler-specific DST edge case behavior at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Write a Regression Test Against the Actual Transition Dates
&lt;/h2&gt;

&lt;p&gt;Because this bug only manifests on two specific calendar dates per year, it's easy to fix once and have it silently regress after a scheduler library upgrade or a refactor. A test that explicitly simulates the transition dates catches this before it reaches production again.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transitionDates2026&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-03-08&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-11-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// US DST dates&lt;/span&gt;
&lt;span class="nx"&gt;transitionDates2026&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`job scheduling resolves correctly around &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scheduledRuns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolveScheduleForDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cronExpression&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;scheduledRuns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&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="c1"&gt;// exactly one run, not zero or two&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 7: Document the Decision for the Next Engineer
&lt;/h2&gt;

&lt;p&gt;Whatever behavior you land on, document it directly next to the cron configuration or scheduling code, not in a separate runbook that will be forgotten. The next engineer who inherits this code and notices a job "missing" a run during a DST week needs to find the explanation in thirty seconds, not rediscover the whole investigation from scratch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://137foundry.com" rel="noopener noreferrer"&gt;Our automation team&lt;/a&gt; has debugged this exact failure pattern across multiple client systems, almost always billing or reporting jobs scheduled inside the 1-3 AM window without anyone realizing that window is where DST transitions live. There's a broader set of related date-handling patterns, including how to test them properly, in &lt;a href="https://137foundry.com/articles/date-time-handling-code-snippets-timezone-dst-parsing" rel="noopener noreferrer"&gt;our recent piece on date and time code snippets&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Bug Survives So Long in Production
&lt;/h2&gt;

&lt;p&gt;Part of what makes this particular failure mode so persistent is that it fails silently in the spring-forward direction and loudly, but confusingly, in the fall-back direction. A skipped run rarely triggers an alert unless someone explicitly built monitoring for "job X did not run today," which is a less common alerting pattern than "job X threw an error," since a skipped run produces no error at all, just an absence. A duplicated run, meanwhile, often does get noticed, but the symptom, two of something that should have happened once, gets attributed to a retry mechanism, a deploy race condition, or a load balancer quirk long before anyone thinks to check the calendar for a DST transition date. Teams frequently spend hours chasing the wrong hypothesis before someone notices the date coincidence. Standard monitoring guidance, including the "dead man's switch" pattern documented across most observability platforms, generally recommends alerting on the absence of an expected event within a time window rather than only alerting on explicit errors, precisely because failure modes like a skipped cron run produce no error signal on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloud Scheduler Services Aren't Automatically Immune Either
&lt;/h2&gt;

&lt;p&gt;It's tempting to assume that migrating a cron job to a managed cloud scheduling service (AWS EventBridge Scheduler, Google Cloud Scheduler, and similar) sidesteps this entire problem, since these are professionally maintained systems built by teams with far more scheduling expertise than an in-house cron setup. In practice, these services generally do handle DST transitions correctly for the timezone-aware scheduling they explicitly support, but the risk shifts to how your own job configuration specifies its schedule. A job configured with a raw UTC cron expression on a managed scheduler sidesteps DST ambiguity by definition, since UTC never observes daylight saving time, but a job explicitly configured against a named local timezone still depends on getting that specific configuration right. &lt;a href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/managing-schedule-timezone.html" rel="noopener noreferrer"&gt;AWS's own documentation on EventBridge Scheduler&lt;/a&gt; spells out its specific timezone and DST handling directly, and it's worth reading your specific provider's equivalent page rather than assuming "managed service" means "problem solved automatically."&lt;/p&gt;

&lt;h2&gt;
  
  
  Container Orchestration Adds Its Own Layer of Timezone Uncertainty
&lt;/h2&gt;

&lt;p&gt;Teams running scheduled jobs inside containers, via Kubernetes CronJobs or similar orchestration, face an extra layer worth checking: the container's own configured timezone (often UTC by default in most base images regardless of the host machine's timezone) may differ from what an engineer assumes when reading a schedule definition. The &lt;a href="https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/" rel="noopener noreferrer"&gt;Kubernetes documentation on CronJob&lt;/a&gt; explicitly notes that CronJob schedules are interpreted according to the kube-controller-manager's configured timezone, which is a detail worth confirming directly rather than assuming it matches either the container image's default or any individual engineer's local expectation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;A cron job that silently skips or duplicates during a DST transition isn't a scheduler bug, it's an unhandled edge case in wall-clock scheduling that every DST-observing region creates twice a year. Confirm the transition type, check your scheduler's documented behavior, make critical jobs idempotent, move genuinely critical schedules outside the transition window, and write a regression test against the actual calendar dates so the fix survives the next refactor.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>automation</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
