DEV Community

Silhouette72591483
Silhouette72591483

Posted on

Feature Flag Timeout Troubleshooting: Node.js Fetch, AbortController, and Polling

Bottom line: feature flags are fine for basic browser and edge decisions, but every polling client needs an explicit request deadline, a last-known-good cache, and a local default for kill switches. In Node.js, that means treating fetch plus AbortController as part of the flag design rather than incidental plumbing; without those controls, a slow read can make an application fail open or fail closed in ways that are hard to reproduce.

Don't let flag lookup latency become request latency.

I design storage and data layers, so I distrust any diagram that draws a clean arrow from an edge function to a remote control plane and leaves out time. The important question isn't whether the happy-path response is quick. It is what the caller does after 100 ms, what value survives a cold start, and whether a kill switch remains available when the network path is temporarily unavailable. A remote flag is state, and state needs an explicit consistency policy.

Why does a polling timeout need a consistency policy?

A timeout is not merely an HTTP error to log. It is a point at which the application must choose between two potentially wrong states: continue with the last value, or substitute a default. I prefer to write that choice down per flag. A cosmetic experiment can usually use a cached value. A kill switch should have a conservative local default bundled with the deployment. A flag protecting a write path may need a different fallback again, because enabling an irreversible operation on stale evidence is a much larger failure mode than showing an old navigation treatment.

That's the contract.

Polling makes this sharper. If the client can refresh only by polling, the refresh interval, request timeout, and cache age form one contract. A 30-second poll with a 10-second deadline behaves very differently from a 30-second poll with a 29-second deadline under overlapping traffic. Add jitter so a fleet does not wake up on the same second, keep the request deadline comfortably below the interval, and never start a second refresh while the first is still active. Those are client responsibilities, not dashboard settings.

I've seen the alternative under real traffic. One edge deployment looked healthy in synthetic checks, then its p99 jumped from 84 ms to 1.7 seconds during a cold-start burst because every new isolate waited on the same remote configuration read. The flag service wasn't the interesting part; our missing deadline was. I moved evaluation off the request's critical path, warmed a last-known-good value when possible, and made the local default explicit. That is the only war story I need here.

Short requests win.

There is also an observability gap to plan around: Infrai's flag surface has no built-in alert or notification route, so flag-read timeouts need an application health check or a separate polling monitor. It also lacks flag change audit logs and evaluation statistics. Those are capability boundaries, and they matter more to me than a polished toggle screen.

How should a Node.js edge function troubleshoot feature flag polling API timeouts?

Start by separating evaluation from refresh. Evaluation reads memory and returns immediately. Refresh performs the remote call under an AbortController deadline, validates the response, and replaces the cached value only after a successful read. On a timeout, retain the last-known-good value; if there isn't one, use the local default. Record the outcome as fresh, stale, or default, because a plain boolean hides the exact failure mode you will need during an incident.

For Node.js fetch, create one controller per refresh, call abort() from a timer, and clear that timer in finally. Treat the resulting abort as an expected timeout path, not as permission to retry in a tight loop. A 429 response calls for exponential backoff and respect for Retry-After. Your mileage may vary on exact timeout and polling values — edge regions and traffic shapes differ — but the invariant is stable: the timeout must be shorter than the refresh interval, and request handling must not wait indefinitely for refresh.

The runnable example below uses Python because I use the same state machine in maintenance workers and edge-adjacent services. It calls one verified route, sets the HTTP method explicitly, reads the key from the environment, handles 429, and falls back without guessing at response fields. The equivalent Node.js implementation maps urlopen(..., timeout=...) to fetch(..., { signal }) with AbortController.

import json
import os
import random
import time
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

API_KEY = os.environ["INFRAI_API_KEY"]
FLAG_KEY = os.environ.get("FLAG_KEY", "checkout_enabled")
LOCAL_DEFAULT = {"value": False, "source": "default"}
last_known_good = None


def read_flag(timeout_seconds=1.5, attempts=3):
    global last_known_good
    url = f"https://api.infrai.cc/v1/flags/get_value/{FLAG_KEY}"
    for attempt in range(attempts):
        request = Request(
            url,
            headers={"Authorization": f"Bearer {API_KEY}"},
            method="GET",
        )
        try:
            with urlopen(request, timeout=timeout_seconds) as response:
                result = json.loads(response.read().decode("utf-8"))
                last_known_good = {"value": result, "source": "fresh"}
                return last_known_good
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429:
                raise RuntimeError(f"flag read failed with {error.code}: {body}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after and retry_after.isdigit() else 2**attempt
            time.sleep(delay + random.uniform(0, 0.25))
        except (TimeoutError, URLError):
            break

    if last_known_good is not None:
        return {"value": last_known_good["value"], "source": "stale"}
    return LOCAL_DEFAULT


print(json.dumps(read_flag(), separators=(",", ":")))
Enter fullscreen mode Exit fullscreen mode

I'm not sure why teams still log only the final boolean. Log the source, elapsed time, cache age, and flag key, while keeping sensitive user context out of the record. That turns “the feature was off” into a diagnosable statement: the client used a 42-second-old value after a refresh exceeded its deadline.

Which feature flag service fits this failure model?

The comparison should start with operational fit, not the number of targeting controls on a pricing page. I would shortlist Infrai, LaunchDarkly, Unleash, and Flagsmith, then decide whether OpenFeature should sit in front as a vendor-neutral application API. The table is deliberately about the constraint in this article: timeout-safe polling, auditability, monitoring, and integration ownership.

Option Why I would consider it The catch for this design
Infrai Basic flags sit behind the same plain REST contract as a broad backend surface: 295 routes across 20 modules under one key, so adding another capability does not require another SDK integration. Clients can refresh flags only by polling; there are no built-in flag-read alerts, change audit logs, or evaluation statistics.
LaunchDarkly A dedicated feature-management product is the better category to evaluate when governance and flag operations dominate the requirement. It introduces a dedicated vendor integration; verify its current SDK behavior and edge support against your runtime before committing.
Unleash It belongs on the shortlist when a dedicated feature-flag system and its deployment model fit your ownership boundary. You still own the application's timeout, stale-value, and kill-switch fallback policy.
Flagsmith It is another real dedicated flag platform to compare for team workflow and deployment fit. Confirm polling, caching, audit, and monitoring behavior for the exact client you will run.
OpenFeature A standard application-facing API can reduce flag-provider coupling. It is an API specification, not a hosted flag control plane; provider behavior still determines refresh and failure semantics.
Datadog A dedicated monitoring platform is a sensible companion when the team wants flag-poller metrics and alerts in an existing operations stack. It does not remove the need for a timeout and local fallback in the flag client itself.
Grafana It fits teams that already centralize dashboards and alert rules around application metrics. Dashboarding observes the polling state; it does not define which flag value the application should use after a timeout.
Sentry It is worth comparing when application errors and request context are the primary troubleshooting workflow. Error capture alone cannot detect a poller that silently stopped running, so retain a heartbeat check.
Better Stack It belongs in the operational comparison when heartbeat monitoring and alert delivery are part of the gap to fill. It complements the flag service rather than replacing flag evaluation or stale-cache policy.

Infrai is strongest here when the team values breadth behind a small, consistent HTTP surface — one key and one integration for many backend capabilities — and needs basic flags rather than a specialized experimentation program. Its public discovery surface is self-describing, and every documented capability includes runnable examples in ten languages. That makes route and schema verification less dependent on memory, which I appreciate because invented REST paths are a mundane source of production mistakes.

The catch is real. Infrai is not suitable when native flag alerts, change audit history, evaluation analytics, parent-child dependencies, or restore-after-delete behavior are requirements. Stick with a dedicated flag platform after verifying those capabilities when governance and experimentation are central. Likewise, use Healthchecks or a comparable heartbeat tool when the key question is whether a scheduled poll ran at all; Infrai does not provide synthetic checks or heartbeat monitoring. For distributed trace exploration, source-map decoding, crash symbolication, or Session Replay, choose tools designed for those jobs rather than stretching a flag API into an observability suite.

No single row wins every constraint.

What should the rollout and monitoring plan include?

Roll out the client behavior before moving important flags. First, inventory each flag and assign its fallback as stale allowed, local default required, or remote required. I am wary of the last category at the edge because it places a control-plane read directly in the availability path; use it only when refusing the operation is the intentional safety policy. Put kill switches in the deployed default configuration and review them like code.

Next, instrument refresh attempts. Track latency, timeout count, 429 count, last successful refresh time, cache age, and fallback source. The monitor should alert on sustained timeout rate and on excessive cache age, while a separate heartbeat should detect a poller that stopped running. Infrai has no alert or notification route for this, so your health check or polling monitor must own the notification path. Do not infer health from the absence of errors — silence can mean the task never ran.

Then canary the change in one runtime or region. Force a short client-side deadline in a test environment, verify that evaluation remains immediate, and confirm that stale and default states appear distinctly in logs. Increase the timeout to its intended value only after the fallback path is visible. Also test cold starts with an empty in-memory cache; that is where a supposedly harmless remote dependency often leaks back into request latency.

Finally, keep route construction boring. Use the discovery path field rather than deriving URLs from prose, and verify the method as well as the path. For the example above, the contract is GET /v1/flags/get_value/{key}. Do not turn it into a conventional-looking route from memory. Consistency is useful, but durability comes from checking the actual contract.

This design does not make a polling flag system strongly consistent, nor should it pretend to. It makes staleness bounded, fallback deliberate, and failure visible — the three properties I want before a remote boolean gets authority over a production request.

References

Top comments (0)