DEV Community

xanderblack5716
xanderblack5716

Posted on

Per-Request Deadlines Beat Polling APIs for Feature Flags (In Edge Functions)

Short answer: use a request-scoped deadline for the feature-flag fetch, preserve the last known decision, and emit one bounded decision record; don't let background polling consume the edge function's remaining execution time. This is the better default when a healthtech team must reconstruct why a customer saw a particular workflow without retaining every poll as evidence.

The decision is about signal quality, not collecting the largest possible pile of telemetry. An aborted request, a remote timeout, an invalid response, and a valid flag decision are different outcomes. If the implementation collapses them into false, troubleshooting becomes guesswork. If it logs every retry, the useful evidence disappears inside repeated payloads and high-cardinality labels.

This architecture decision record compares two approaches: a deadline attached to each evaluation request, and a poller that refreshes configuration in the background. The choice is the request deadline, provided that the application can define a safe last-known or local fallback. The background poller remains valid for long-lived processes with controlled lifecycle hooks and enough time to refresh outside a customer request.

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

Start by separating three clocks. There is a connection budget, an overall evaluation budget, and the edge function's total execution budget. They can't be treated as one number. The evaluation budget must expire early enough to leave time for the application to apply a fallback, return a response, and export a small evidence record. A timeout that fires at the platform's hard limit is operationally useless because no cleanup budget remains.

In current Node.js, fetch is available as a global, and AbortController can provide the signal passed to it. The controller describes cancellation; it doesn't define the business result. Code around the fetch still has to map cancellation into an explicit decision source such as remote, last_known, or local_default. It also has to distinguish a caller cancellation from a deadline created specifically for flag evaluation. That distinction belongs in application state, not in a guessed interpretation of one exception string.

The critical path is short: derive a deadline from the remaining request budget, start the fetch with its abort signal, validate the response, select the decision, and write one record before responding. A diagnostic reproduction can isolate connection and total transfer limits without introducing an SDK or a product-specific route:

curl --silent --show-error \
  --connect-timeout 0.2 \
  --max-time 0.8 \
  --header 'Accept: application/json' \
  --header 'X-Request-ID: 7f52c9b1' \
  'https://flags.example.invalid?key=care-plan-export'
Enter fullscreen mode Exit fullscreen mode

The .invalid top-level domain is reserved for examples, so this command documents the shape of the probe rather than claiming a live API. In the application, the equivalent total budget should be enforced by the abort signal supplied to fetch. The response validator should accept only the schema and status codes the flag service contract defines; a syntactically valid JSON body isn't automatically a valid decision.

One subtle failure mode deserves more space. Suppose an edge request has 1,000 ms left and the flag client begins an 800 ms fetch, then retries after a timeout. Even if each attempt is individually bounded, the combined work can cross the request boundary. The response may then vanish before the evidence record is exported. Reusing one absolute deadline across attempts avoids that accounting error. A retry receives only the time still available, not a fresh 800 ms allowance. Better yet, keep retries out of the customer path unless a measured latency distribution shows that a second attempt can finish within the same deadline and changes enough outcomes to justify its extra requests. Your mileage may vary because edge runtime limits, upstream latency, and export behavior differ; a trace from the deployed path resolves that uncertainty.

Keep it bounded.

Invariants and failure boundaries

The first invariant is semantic: a flag decision must always include its source. false from a remote evaluation is not equivalent to false from a local safety default. For a healthtech workflow, that source can explain why a customer was shown a manual review path instead of an automated export without recording patient content. The evidence record needs the flag key, a non-sensitive variant identifier, the decision source, the outcome class, elapsed time in a coarse bucket, a request or trace correlation identifier, and the configuration revision when the contract exposes one.

The second invariant is a cardinality budget. Flag keys and decision sources are bounded dimensions; raw request IDs and trace IDs are not. Put correlation identifiers in trace or log fields that support lookup, not in metric labels. Do the same with error text. A metric such as evaluation count can use a small outcome set (success, deadline, invalid_response, network_error), while the detailed record carries the correlation needed for a single incident. Otherwise, one identifier per request turns a useful counter into an unbounded series factory.

The third invariant is retention by purpose. Metrics answer whether timeouts are increasing. Traces show where the budget was spent. Decision records establish what the application chose. They don't need identical retention. For example, if the incident-review window is R days and the system produces E evaluations per day at B stored bytes per bounded record, the base storage estimate is R × E × B, before replication and indexing overhead. That equation is more useful than a fashionable retention number because every team can insert its own review window, measured volume, and encoded record size.

No payloads.

Timeouts are outcomes.

The failure boundary must also be explicit. The remote evaluator may be late, unreachable, or contract-invalid; the edge function must still make a policy-approved decision. But a fallback isn't universally safe. A stale decision might be acceptable for a cosmetic rollout and unacceptable for a flag that gates a clinical or privacy control. Such controls should fail according to a separately reviewed safety policy, and some should not be modeled as ordinary feature flags at all. Observability cannot repair an ambiguous policy after the request has ended.

Finally, propagate standard trace context only across services that are allowed to receive it, and never place protected health information in baggage, flag keys, metric attributes, or URLs. W3C Trace Context defines interoperable correlation headers; it doesn't grant permission to copy sensitive data. This boundary matters because telemetry often has broader operator access and longer retention than the transactional record.

Decision table: bounded request versus background refresh

The comparison changes once every log line is counted as bytes stored and every label as a potential series. A background poller can make reads cheap on the request path, yet its telemetry can quietly become the dominant signal if each unchanged refresh is recorded.

Criterion Request-scoped deadline Background polling
Edge lifecycle Fits a single invocation and leaves a cleanup reserve Depends on timers or process reuse that the runtime may not preserve
Decision freshness Fetches on demand, subject to the deadline Uses the most recently refreshed snapshot
Failure semantics Each request selects an explicit remote or fallback source Requests can continue from cache while refresh health is reported separately
Evidence volume One bounded decision record per relevant request, with sampling available Refresh logs can repeat even when no configuration changes
Latency cost Adds remote latency until success or abort Usually removes remote evaluation from the request path
Best fit Short-lived edge work with a defined fallback Long-lived workers or servers with reliable startup and shutdown behavior

Choose the request-scoped design for the stated edge case because its lifecycle matches the unit of work. Set the evaluation deadline from the remaining request budget, not from a constant copied across runtimes. Record the selected source once. Aggregate timeout counts, and retain detailed records only for the incident horizon that the evidence policy requires.

Sampling needs two lanes. Keep all rare failure outcomes within the approved privacy boundary, because a one-percent sample can erase the incident being investigated. Sample routine successes more aggressively, while retaining aggregate counts so the denominator survives. Tail sampling can help preserve slow or failed traces, but the sampler must see the outcome before making its decision. Head sampling can't know that a fast-looking request will later take the fallback path.

This is also where an apparently small label choice becomes expensive. A flag_key label may be controlled if the registry is bounded. A user_id, request_id, or raw exception message label isn't controlled. Cardinality grows roughly with the product of independent label values, so adding one unbounded dimension can dominate every retention optimization downstream. The useful compromise is boring: low-cardinality metrics for trends, sampled traces for timing, and narrowly retained decision records for reconstruction.

Rejected option, and when it should win

The rejected design is a background loop that polls the flag API and shares an in-memory snapshot with request handlers. It is not suitable when the edge runtime may freeze or discard an isolate between requests, when no reliable startup barrier exists, or when a timer can continue spending the function's budget after the customer work is done. Adding more poll logs doesn't fix those lifecycle assumptions.

Lifecycle decides.

Still, background refresh should win in a long-lived Node.js service when remote latency cannot sit on the request path, the process lifecycle is controlled, and the application has a defined readiness rule for the initial snapshot. In that environment, request handlers read a local immutable snapshot; a separate refresh task exposes a low-cardinality freshness age and refresh outcome. Log a configuration change or a state transition, not every unchanged 200 response. The catch is that the team must test startup, stale-snapshot limits, credential rotation, shutdown cancellation, and the transition from healthy refresh to fallback behavior.

Deployment tests should inject delay at the evaluator boundary and assert the decision source, not merely the HTTP response. A test should also prove that two attempted reads share one absolute budget. Another should send a contract-invalid response and confirm that no unchecked value reaches the workflow. For observability, verify that a timeout increments one counter, creates at most one detailed decision record under the sampling policy, and never turns the request ID into a metric label.

An appender or exporter can buffer writes, which creates another failure boundary: process termination may occur before buffered evidence is flushed. Logback's appender documentation is useful here even outside the Java ecosystem because it makes the output boundary explicit, including lifecycle and synchronization concerns. For an edge function, prefer an export path supported by the runtime and reserve time for it. Don't claim evidence was retained until the storage system has actually accepted it according to the chosen delivery contract.

The final operating rule is concise. Use per-request cancellation where invocation lifetime is the hard boundary; use background polling where process lifetime is dependable. In both cases, preserve the source of the decision, cap cardinality, calculate retention from measured event size and volume, and test the fallback as a first-class path. That produces enough evidence to reconstruct an incident without mistaking noise for certainty.

References

Further reading

Top comments (0)