Summary
Remove the STARTUP_SYNC_TIME_WINDOW blocking gate from ApproovInterceptor (Android) and the equivalent [NSThread sleepForTimeInterval:] gate from the iOS NSURLSession interceptor, and replace it with a proper initialization contract enforced at the JS layer.
Background — The Gate Has Zero Purpose
The service layer exists solely to protect fetch() calls made from React Native JS code. Given that scope, the current 2,500 ms startup blocking gate in ApproovInterceptor.intercept() (Android) and its iOS equivalent serves no valid purpose. Here is the proof:
There are exactly two ways a JS fetch() can arrive at the interceptor:
Case 1 — Developer did it right (initialize() first):
JS: await ApproovService.initialize()
→ Java/ObjC constructor executes
→ Approov.initialize() called (local, synchronous — no network)
→ isInitialized = true ← set before JS resumes
JS: fetch("https://api.example.com/...")
→ interceptor fires
→ isInitialized = true ← gate NEVER triggers, completely inert
Case 2 — Developer did it wrong (fetch() before initialize()):
JS: fetch("https://api.example.com/...") ← developer error
→ interceptor fires
→ isInitialized = false ← gate triggers
→ OkHttp/URLSession thread parked for 2,500 ms
→ gate clears, request proceeds WITHOUT a valid token
In Case 1 the gate is inert. In Case 2 the gate silently hides a developer error and then lets the unprotected request through anyway. There is no Case 3.
Current Failure Mode on Hostile Networks
On networks that blackhole Approov attestation DNS (attest.api.approov.io), Approov.initialize() never completes, isInitialized is never set to true, and:
- Every parallel startup
fetch() parks an OkHttp/URLSession thread for 2,500 ms
- The OkHttp thread pool (default: 5 threads) is exhausted
- The app freezes on the splash screen with no visible error
This is a confirmed production failure (Samsung Galaxy A34, Android 16, home Wi-Fi with Approov domains blackholed). The fix is to remove the gate entirely.
Required Changes
1. Remove the blocking gate from the Android interceptor
In ApproovInterceptor.java, replace the STARTUP_SYNC_TIME_WINDOW sleep/wait block with an immediate fast-fail:
// REMOVE THIS:
if (!approovService.isInitialized()) {
approovService.setEarliestNetworkRequestTime();
// ... Thread.sleep / wait loop ...
}
// REPLACE WITH:
if (!approovService.isInitialized()) {
throw new IOException("ApproovService.initialize() has not been called. " +
"Ensure initialize() is awaited before making any fetch() calls.");
}
Remove STARTUP_SYNC_TIME_WINDOW, earliestNetworkRequestTime, and setEarliestNetworkRequestTime() from ApproovService.java.
2. Remove the equivalent gate from the iOS interceptor
Remove the [NSThread sleepForTimeInterval:] equivalent blocking path from the iOS NSURLSession swizzle interceptor. Apply the same fail-fast pattern.
3. Enforce await initialize() at the JS layer
Add enforcement mechanisms so the contract is self-enforcing rather than documentation-only. Implement the following (in priority order):
Option A — Promise Queue (Highest Priority — handles "forgot to await" gracefully)
Store the init promise at module level. The patched fetch() always awaits it internally:
let _initPromise = null;
export function initialize(...args) {
_initPromise = NativeModules.ApproovService.initialize(...args);
return _initPromise;
}
const _originalFetch = global.fetch;
global.fetch = async function(...args) {
if (!_initPromise) {
throw new Error(
'ApproovService.initialize() must be called before fetch(). ' +
'See https://approov.io/docs/react-native/quickstart'
);
}
await _initPromise; // no-op if already resolved; awaits if still in flight
return _originalFetch(...args);
};
Why: JS is single-threaded. Once _initPromise resolves, await _initPromise is instantaneous forever after. If the developer forgot to await, the fetch() call awaits for them — silently correct on good networks. If initialize() was never called, a clear, immediate error is thrown. Critically: on a network that blackholes Approov DNS, _initPromise hangs inside JS (not inside OkHttp), and can be raced with Promise.race() + a timeout by the developer.
Option B — React Provider Pattern
Provide an <ApproovProvider> component that renders children only after init resolves:
function ApproovProvider({ children, fallback = null }) {
const [ready, setReady] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
ApproovService.initialize()
.then(() => setReady(true))
.catch(setError);
}, []);
if (error) return <ErrorScreen error={error} />;
if (!ready) return fallback; // splash screen while initializing
return children;
}
Since React will not render children until ready = true, no child component can call fetch() in a useEffect or event handler before initialization completes. This is the same pattern used by Firebase, Amplify, and Segment.
Option C — TypeScript API Shape
Make initialize() return the protected fetch reference, so the function physically does not exist before init:
// Developer must do:
const { fetch } = await ApproovService.initialize();
// fetch() as a symbol simply does not exist before this line
This is a breaking API change but provides the strongest compile-time guarantee.
Option D — ESLint Rule (Build-time enforcement)
Provide a custom ESLint rule that errors if fetch() is called in a scope that does not have await ApproovService.initialize() preceding it. Enforceable in CI.
Acceptance Criteria
References
Summary
Remove the
STARTUP_SYNC_TIME_WINDOWblocking gate fromApproovInterceptor(Android) and the equivalent[NSThread sleepForTimeInterval:]gate from the iOSNSURLSessioninterceptor, and replace it with a proper initialization contract enforced at the JS layer.Background — The Gate Has Zero Purpose
The service layer exists solely to protect
fetch()calls made from React Native JS code. Given that scope, the current 2,500 ms startup blocking gate inApproovInterceptor.intercept()(Android) and its iOS equivalent serves no valid purpose. Here is the proof:There are exactly two ways a JS
fetch()can arrive at the interceptor:Case 1 — Developer did it right (
initialize()first):Case 2 — Developer did it wrong (
fetch()beforeinitialize()):In Case 1 the gate is inert. In Case 2 the gate silently hides a developer error and then lets the unprotected request through anyway. There is no Case 3.
Current Failure Mode on Hostile Networks
On networks that blackhole Approov attestation DNS (
attest.api.approov.io),Approov.initialize()never completes,isInitializedis never set totrue, and:fetch()parks an OkHttp/URLSession thread for 2,500 msThis is a confirmed production failure (Samsung Galaxy A34, Android 16, home Wi-Fi with Approov domains blackholed). The fix is to remove the gate entirely.
Required Changes
1. Remove the blocking gate from the Android interceptor
In
ApproovInterceptor.java, replace theSTARTUP_SYNC_TIME_WINDOWsleep/wait block with an immediate fast-fail:Remove
STARTUP_SYNC_TIME_WINDOW,earliestNetworkRequestTime, andsetEarliestNetworkRequestTime()fromApproovService.java.2. Remove the equivalent gate from the iOS interceptor
Remove the
[NSThread sleepForTimeInterval:]equivalent blocking path from the iOSNSURLSessionswizzle interceptor. Apply the same fail-fast pattern.3. Enforce
await initialize()at the JS layerAdd enforcement mechanisms so the contract is self-enforcing rather than documentation-only. Implement the following (in priority order):
Option A — Promise Queue (Highest Priority — handles "forgot to await" gracefully)
Store the init promise at module level. The patched
fetch()always awaits it internally:Why: JS is single-threaded. Once
_initPromiseresolves,await _initPromiseis instantaneous forever after. If the developer forgot toawait, thefetch()call awaits for them — silently correct on good networks. Ifinitialize()was never called, a clear, immediate error is thrown. Critically: on a network that blackholes Approov DNS,_initPromisehangs inside JS (not inside OkHttp), and can be raced withPromise.race()+ a timeout by the developer.Option B — React Provider Pattern
Provide an
<ApproovProvider>component that renders children only after init resolves:Since React will not render children until
ready = true, no child component can callfetch()in auseEffector event handler before initialization completes. This is the same pattern used by Firebase, Amplify, and Segment.Option C — TypeScript API Shape
Make
initialize()return the protectedfetchreference, so the function physically does not exist before init:This is a breaking API change but provides the strongest compile-time guarantee.
Option D — ESLint Rule (Build-time enforcement)
Provide a custom ESLint rule that errors if
fetch()is called in a scope that does not haveawait ApproovService.initialize()preceding it. Enforceable in CI.Acceptance Criteria
STARTUP_SYNC_TIME_WINDOWconstant removed fromApproovService.javaearliestNetworkRequestTimeandsetEarliestNetworkRequestTime()removed from AndroidApproovInterceptor.intercept()throwsIOExceptionimmediately ifisInitialized = falseNSURLSessiongate removed and replaced with fast-fail<ApproovProvider>(Option B) as the recommended React patternREFERENCE.mdandTROUBLESHOOTING.mdupdated with the new contractawait initialize()is brokenReferences
ApproovInterceptor.java(3.5.12): https://github.com/approov/approov-service-react-native/blob/3.5.12/android/src/main/java/io/approov/reactnative/ApproovInterceptor.javaApproovService.java(3.5.12): https://github.com/approov/approov-service-react-native/blob/3.5.12/android/src/main/java/io/approov/reactnative/ApproovService.javaCHANGELOG.md3.5.10 entry: "Initialization Race Conditions Fixed — Added earlyisInitialized()checks to bypass existingThread.sleepblocking loops"