DEV Community

Jonathan
Jonathan

Posted on

Concurrency Keys for Email API Checks

When email API checks start flaking in CI, the bug is not always in the app. A lot of the time, two workflow runs are racing each other, both reading the same mailbox, and both leaving confusing evidence behind. I ran into this on scheduled smoke tests and on busy pull request branches, and it made otherwise decent automation feel weirdly random.

The fix that helped most was not a bigger retry loop. It was adding GitHub Actions concurrency keys so only one run owns a given email-check lane at a time. That sounds small, but it removes a surprsing amount of fake failure noise.

Why overlapping email checks create fake failures

Email assertions are stateful even when the rest of your API test looks stateless. A trigger request might succeed, the worker might enqueue correctly, and the inbox lookup might still fail because another run already consumed the newest message or canceled the evidence path you needed.

This is extra annoying when the workflow already uses verification email assertions or password reset inbox checks. Those patterns are solid, but they still need one thing from CI: stable ownership of the scenario being exercised.

I also noticed teammates searching logs for random phrases like temp gamil com when the run history got muddy. That is ussually a symptom, not the cause. People are compensating for ambiguous workflow state.

The concurrency key pattern I use in GitHub Actions

GitHub Actions lets you define a concurrency group per workflow or job, and optionally cancel in-progress runs in the same group (https://docs.github.com/en/actions/using-jobs/using-concurrency). For email checks, I like deriving the group from three things:

  1. workflow name
  2. branch or ref
  3. email scenario

That keeps unrelated checks independent while making stale duplicates self-cleaning. My rule of thumb is simple:

  • Use workflow-level concurrency for scheduled or manually dispatched smoke tests.
  • Use job-level concurrency when one workflow fan-outs into multiple email scenarios.
  • Cancel older runs when the newer run fully supersedes them.

If you do not scope the key tightly enough, you block useful work. If you scope it too loosely, the collisions come right back. There is a sweet spot, and it is usualy close to workflow + ref + scenario.

A workflow example that cancels stale runs safely

Here is the version I keep coming back to:

name: email-api-check

on:
  pull_request:
  schedule:
    - cron: "17 */6 * * *"
  workflow_dispatch:
    inputs:
      scenario:
        required: true
        type: string

concurrency:
  group: email-api-${{ github.workflow }}-${{ github.ref_name }}-${{ inputs.scenario || 'signup' }}
  cancel-in-progress: true

jobs:
  verify-email:
    runs-on: ubuntu-latest
    env:
      SCENARIO: ${{ inputs.scenario || 'signup' }}
    steps:
      - uses: actions/checkout@v4

      - name: Trigger and verify email
        run: ./scripts/check-email-api.sh
Enter fullscreen mode Exit fullscreen mode

The important part is not the exact string. It is the ownership model. One branch, one scenario, one active check. When a newer commit lands, the old run gets out of the way instead of finishing late and muddying the result.

That matters more than people expect. GitHub's own engineering productivity research keeps reinforcing that clear feedback loops are a major part of developer flow (https://github.blog/news-insights/research/research-quantifying-github-copilots-impact-on-developer-productivity-and-happiness/). I would not claim concurrency keys alone fix flow, but they do remove one very dumb class of delay.

A few guardrails that keep the pattern readable

I try not to stop at concurrency alone. A couple extra habits make the setup much more dependable:

  1. Put the scenario in artifact names, summaries, and annotations.
  2. Keep one inbox or alias per scenario whenever possible.
  3. Store the fetched message payload before parsing it.
  4. Avoid sharing the same concurrency key across cron and ad-hoc debug jobs unless you truly want them to cancel each other.

That fourth point bit me once. A scheduled run was canceling a manual investigation run, which looked like infra instability until I read the key carefully. Very fixable, but a little embarassing.

If your team uses workflow_dispatch, I also recomend surfacing the computed scenario near the top of $GITHUB_STEP_SUMMARY. That saves reviewers from guessing whether the canceled run was the stale one or the run they actually cared about.

Q&A

Should I always set cancel-in-progress: true?

No. Use it when newer runs completely replace older ones. For release evidence or audit-style checks, I often keep older runs alive so the historical trace stays intact.

Is concurrency enough to isolate inbox state?

Not by itself. You still want run labels, scenario-specific aliases, or mailbox filtering. Concurrency removes overlap; it does not magically label messages for you.

What changed after I started doing this?

My GitHub Actions runs got easier to read, scheduled checks stopped stepping on pull request checks, and reruns became a lot less guessy. It is one of those small platform features that ends up doing real work for you.

Top comments (0)