All Articles

Decoupling Claim Timeouts from Feature Flags

· 9 min read · Humza Tareen
Feature Flags React TypeScript UX Timers

On the review platform I work on, experts claim tasks from a shared pool and work them under a configurable deadline. Operators control that behavior through the admin panel: they can turn the expert pool on or off, tune routing, and set a claim timeout so work does not sit indefinitely with one person. For a long time, those knobs felt independent in the UI—but under the hood, timeout enforcement was accidentally married to the pool feature flag. This post is about what broke when an admin flipped that switch to stop new claims, how we decoupled policy from presentation, and how we tightened the reviewer experience so deadlines stay legible even when the pool is off.

The problem: one flag, two meanings

The platform exposes an “expert review pool” toggle. When it is enabled, tasks enter a pooled queue; reviewers claim items, and a claim timeout applies so stale assignments return to the queue. Admins configure that timeout in the same settings surface. The bug was subtle: several code paths treated “pool enabled” as a proxy for “timeouts matter.” When operations disabled the pool to pause intake—without intending to change fairness for work already in flight—the countdown for already-claimed tasks stopped rendering and, worse, the enforcement path that should release expired claims stopped running as well.

Reviewers who had claimed tasks suddenly appeared to have unlimited time. From their perspective the timer vanished or froze; from the system’s perspective we had coupled “should we show pool UX?” with “should we honor the claim deadline?” That coupling is dangerous because feature flags are operational levers. People toggle them during incidents, experiments, and policy changes. Deadlines are contracts. They should not disappear because you turned off a marketing-style switch.

Decoupling configuration, APIs, and background jobs

The fix started with vocabulary. We separated three concepts: whether expert review is enabled at all, whether the shared pool is accepting new claims, and whether a non-zero claim timeout is configured for claimed work. Admins can now set and persist the claim timeout even when the pool is disabled. The value is not “pool metadata”; it is a scheduling constraint on the claim relationship itself.

On the server, the task detail endpoint had been omitting pool context whenever the pool flag was false, which made the client assume “no timer.” We changed the contract so claimed tasks always receive poolInfo, including an explicit poolDisabled flag when intake is paused. The UI can branch on that without inferring state from missing fields.

The stale-claim release job was gated behind expertPoolEnabled, which meant disabling the pool silently disabled reclamation. We widened the gate so the cron still runs whenever review is active—pool on or off—by expressing the condition as “pool enabled or expert review enabled,” rather than treating the pool as the sole source of truth for lifecycle maintenance.

const shouldRunStaleClaimSweep =
  flags.expertPoolEnabled || flags.enableExpertReview;

if (!shouldRunStaleClaimSweep) return;

await releaseClaimsPastDeadline({
  graceMs: GRACE_PERIOD_MS,
});

That one-line widening is the kind of change that looks trivial in a diff but restores an invariant operators assumed all along: if a task is claimed under a timeout policy, the platform keeps enforcing that policy until the claim ends or the policy changes.

We also added lightweight telemetry on the client and server so we could verify the fix in production without relying on anecdotes: events when the timer mounts with poolDisabled: true, when a submit is blocked for expiry, and when the sweep actually releases a row. Those counters made it obvious during rollout whether any environment still served stale responses missing poolInfo, and they gave support a single dashboard to answer “is the timer supposed to be off?”—no, it never is when a claim exists.

Front-end checks now ask whether a timeout applies to the claim, not whether the pool carousel is visible:

function claimTimeoutApplies(task: TaskDetail): boolean {
  if (!task.claim) return false;
  if (task.claimTimeoutMinutes == null || task.claimTimeoutMinutes <= 0) {
    return false;
  }
  // Pool may be disabled; review may still be on—timeouts stand alone.
  return task.flags.enableExpertReview;
}

Three-phase countdown UX

The original timer was plain monospace text that counted down until it abruptly switched to “Expired.” There was no ramp, no affordance for urgency, and no visual continuity between “you have time” and “you are out of time.” We redesigned the component around three explicit phases mapped from remaining duration.

  • Normal: neutral styling, standard typography, steady tick.
  • Urgent: when fewer than fifteen minutes remain, the module shifts to an amber treatment—border, label, and iconography—so the reviewer perceives narrowing margin without panic.
  • Expired: red palette, stronger heading copy (“Time Expired”), and a subtle pulse on the container so the state reads as terminal, not merely “zero on the clock.”

The phase logic is intentionally boring TypeScript: compare remaining milliseconds to thresholds, derive a discriminated union, and feed that into class names and copy. Boring is good here; you do not want animation timing to drift from authorization rules.

type CountdownPhase = "normal" | "urgent" | "expired";

const URGENT_MS = 15 * 60 * 1000;

function getCountdownPhase(remainingMs: number): CountdownPhase {
  if (remainingMs <= 0) return "expired";
  if (remainingMs <= URGENT_MS) return "urgent";
  return "normal";
}

export function ClaimTimer({ deadline }: { deadline: Date }) {
  const remainingMs = useRemainingMs(deadline);
  const phase = getCountdownPhase(remainingMs);

  return (
    <section className={cn("claim-timer", `claim-timer--${phase}`)}>
      <header>
        {phase === "expired" ? "Time Expired" : "Claim time remaining"}
      </header>
      <output aria-live="polite">{formatDuration(remainingMs)}</output>
    </section>
  );
}

Pairing aria-live="polite" with restrained updates avoids shouting at screen-reader users every second while still announcing the phase transitions that matter.

Blocking expired actions with explanations

After expiry, we hard-disabled the upload control and the final submit action so optimistic UI could not slip past server validation. A disabled button without copy is worse than no button: it looks like a bug. We added a compact “disabled reason” line sourced from the same predicate the buttons use, so reviewers see a complete sentence instead of a grey rectangle.

function SubmitBar({ task }: { task: TaskDetail }) {
  const expired = isClaimExpired(task);
  const canSubmit = !expired && task.uploadsComplete;

  return (
    <footer>
      <button type="submit" disabled={!canSubmit}>
        Submit review
      </button>
      {!canSubmit && (
        <p className="disabled-reason" role="status">
          {expired
            ? "Claim timeout expired — contact admin for extension."
            : "Finish required uploads before submitting."}
        </p>
      )}
    </footer>
  );
}

The copy names the failure mode and the remediation path. That reduces support pings and prevents people from hammering the network with requests the API will reject anyway.

The hidden five-minute safety net

While tracing the cron path, we discovered a five-minute grace buffer baked into the release logic. It was never documented in the admin UI. Reviewers who saw “Time Expired” in the product could still complete uploads for several minutes before the job actually cleared the claim. That asymmetry produced mistrust: the UI said one thing, reality said another.

Removing the buffer outright would have been risky—there are legitimate races between “user clicked submit” and “sweep runs”—so we chose transparency instead of surgery. Admin hints now describe the grace margin in plain language, and the visible countdown accounts for it where appropriate so “expired” in the UI aligns with “you should wrap up now” rather than implying an immediate server-side eviction in the same second.

UI adaptations when the pool is off

Some affordances only make sense when the pool is accepting work. The “Release task” action returns an item to the shared queue; when the pool is disabled, that destination does not exist in a meaningful way, so we hide the button rather than letting people click into a no-op or confusing error toast.

Copy around the timer adapts too: instead of promising return “to the pool,” we explain that the task will fall back to the admin queue after release or expiry. Small string changes, but they keep the mental model aligned with routing reality.

Testing both worlds

We expanded automated coverage around matrix cases that previously fell through the cracks:

  • Pool enabled, timeout configured and active—happy path.
  • Pool disabled, expert review still on, timeout active—timer visible, submit blocked after expiry, sweep still eligible to run.
  • Pool enabled, timeout unset—no false urgency, no orphan cron noise.
  • Expired claim with uploads still pending—UI blocks, API rejects, no silent success.
  • Grace boundary—just before and just after the buffer—to ensure messaging and server behavior stay coherent.

Those tests document intent as much as they guard regressions. The next engineer should not have to rediscover that a feature flag once doubled as a scheduling policy.

Closing

Feature flags are excellent for progressive delivery; they are poor substitutes for domain invariants. Claim timeouts belong to the claim, not to the pool carousel. Decoupling them restored predictable fairness, let operators pause intake without freezing clocks, and gave reviewers a calmer, more honest interface—normal, urgent, expired, with words that explain why buttons stop working. If your timers ever “turn off” when a flag flips, trace the enforcement path, widen the gate with intent, and then teach the UI to tell the same story the cron job already knew.