Build CoreOrdered learning track

Background Jobs and Polling Auth

Learn React Authentication, Authorization, Identity & Permission/ACL - Part 067

Background jobs and polling auth for React apps: visibility-aware polling, session expiry, logout cancellation, permission drift, tenant switch, refresh storms, and safe job status UX.

11 min read2004 words
PrevNext
Lesson 67130 lesson track25–71 Build Core
#react#authentication#authorization#polling+4 more

Part 067 — Background Jobs and Polling Auth

Background work is where many React auth bugs hide.

A user logs out, but a polling interval keeps calling /api/jobs/123.

A role is revoked, but the browser still shows completion for an export job containing restricted data.

A user switches tenant, but an old hidden tab keeps polling tenant A while the visible tab shows tenant B.

A token expires, ten pollers fail at once, every poller triggers refresh, and the app creates a refresh storm.

The root mistake:

Treating polling as “just data fetching”.

Polling is a background actor running inside the browser. It must obey the same auth lifecycle as visible UI.


1. What counts as background work?

In production React apps, background work includes:

MechanismCommon purposeAuth risk
setInterval pollingjob status, notifications, dashboardscontinues after logout or tenant switch
TanStack Query refetchIntervalperiodic server syncstale cache or retry storm
visibility/refocus refetchrefresh when tab becomes activestale data appears after auth changed
Web Workerparsing, upload prep, expensive CPU workstale auth context outside React tree
Service Workeroffline cache/background synccached/replayed authenticated requests
SSE/WebSocket reconnectrealtime updatesreconnects under old identity
autosavedrafts/formswrites after permission changes
upload/download progressfile operationsobject permission may change mid-operation
async export/report jobprivate artifact generationstatus/result endpoint can leak metadata

If the work can run while the user is not actively looking at the screen, it needs an auth policy.


2. Mental model

background work = scheduled request + auth context + resource context + cancellation policy

Do not model polling as:

setInterval(fetchData, 5000);

Model it as:

while current auth context is eligible:
  wait according to policy
  run request with abort support
  reconcile response with latest auth context
  render only if still current

The important word is current.

A request may start under one auth state and finish under another.


3. Core invariants

Invariant 1 — no background request without current auth context

A poller must know the current security scope:

type AuthContext = {
  authEpoch: number;
  status: 'anonymous' | 'authenticated' | 'refreshing' | 'expired' | 'revoked';
  userId?: string;
  tenantId?: string;
  sessionId?: string;
  permissionVersion?: string;
};

A request is invalid if the context is missing, stale, or belongs to a previous tenant/session.

Invariant 2 — logout cancels work

Logout must:

cancel timers
abort in-flight requests
clear query cache
close realtime connections
stop upload/autosave tasks
broadcast logout to other tabs
ignore late responses from old auth epoch

Invariant 3 — tenant switch cancels tenant-scoped work

tenant A polling loop must not survive transition to tenant B

Tenant switch is partial logout for tenant-scoped data.

Invariant 4 — permission revocation beats progress continuity

Never assume:

allowed at job start => allowed forever

The status endpoint, output endpoint, download endpoint, and notification endpoint each need authorization.

Invariant 5 — refresh must be single-flight

If ten pollers receive 401 at the same time, there must be one refresh attempt, not ten.

many failed requests -> one refresh coordinator -> safe replay only

Invariant 6 — background failure must not create foreground redirect loops

A hidden polling failure should not repeatedly redirect the visible page to login.

Background auth recovery must be bounded and event-driven.


4. Polling lifecycle

The danger is not one request. The danger is a loop that does the wrong thing repeatedly.


5. Polling policy is a security control

Policy dimensionBad defaultSafer default
while anonymouskeep polling and expect 401stop immediately
while refreshingevery poller refreshespause behind refresh coordinator
hidden tabcontinue high-frequency pollingpause or slow down intentionally
after 401redirect from every requestrefresh once or stop via auth event
after 403retry foreverstop and invalidate permission projection
after tenant switchreuse old keycancel and require tenant-scoped key
after logoutkeep timers aliveabort and clear work
network errorretry foreverexponential backoff + circuit breaker

The Page Visibility API is useful as a lifecycle signal, but visible page is not proof of user presence.


6. Background job authorization model

A background job usually has multiple resources:

job metadata resource
generated output resource
underlying source resources

Example:

POST /api/cases/export
GET  /api/jobs/job_123
GET  /api/exports/export_456/download

Each endpoint needs its own policy.

EndpointAuthorization requirement
create jobcan request operation over selected resource set
read job statuscan view job created by self/delegated actor in tenant
read output metadatacan see generated artifact metadata
download outputcan still access exported data
cancel jobcan cancel own job or has admin/support rule

A status response can leak sensitive information.

Bad:

{
  "id": "job_123",
  "status": "complete",
  "rows": 8942,
  "fileName": "high-risk-investigations.csv"
}

Safer denial:

{
  "type": "https://example.com/problems/forbidden",
  "title": "Access denied",
  "status": 403,
  "code": "JOB_ACCESS_DENIED",
  "correlationId": "req_7b2f"
}

If existence itself is sensitive, return the same public shape as not found and log the true reason server-side.


7. Minimal auth-aware polling primitive

Create a reusable primitive instead of scattering intervals.

type PollContext = {
  authEpoch: number;
  tenantId: string;
  permissionVersion?: string;
};

type PollPolicy = {
  intervalMs: number;
  hiddenIntervalMs?: number;
  stopOnUnauthorized: boolean;
  stopOnForbidden: boolean;
  maxConsecutiveFailures: number;
};

type PollTask<T> = {
  name: string;
  getContext: () => PollContext | null;
  isEligible: (context: PollContext) => boolean;
  run: (context: PollContext, signal: AbortSignal) => Promise<T>;
  onResult: (result: T, context: PollContext) => void;
  onStop?: (reason: string) => void;
};

Implementation sketch:

export function startAuthAwarePolling<T>(task: PollTask<T>, policy: PollPolicy) {
  let stopped = false;
  let timer: number | undefined;
  let controller: AbortController | undefined;
  let failures = 0;

  function stop(reason: string) {
    if (stopped) return;
    stopped = true;
    if (timer !== undefined) window.clearTimeout(timer);
    controller?.abort();
    task.onStop?.(reason);
  }

  function schedule(delay: number) {
    if (stopped) return;
    if (timer !== undefined) window.clearTimeout(timer);
    timer = window.setTimeout(tick, delay);
  }

  async function tick() {
    const context = task.getContext();
    if (!context) return stop('no_auth_context');
    if (!task.isEligible(context)) return stop('not_eligible');

    const startedEpoch = context.authEpoch;
    const startedTenant = context.tenantId;
    controller = new AbortController();

    try {
      const result = await task.run(context, controller.signal);
      const latest = task.getContext();

      if (!latest || latest.authEpoch !== startedEpoch) return stop('auth_epoch_changed');
      if (latest.tenantId !== startedTenant) return stop('tenant_changed');

      failures = 0;
      task.onResult(result, latest);

      const delay = document.visibilityState === 'hidden'
        ? policy.hiddenIntervalMs ?? policy.intervalMs
        : policy.intervalMs;

      schedule(delay);
    } catch (error) {
      if (controller.signal.aborted) return;

      const status = toHttpStatus(error);
      if (status === 401 && policy.stopOnUnauthorized) return stop('unauthorized');
      if (status === 403 && policy.stopOnForbidden) return stop('forbidden');

      failures += 1;
      if (failures >= policy.maxConsecutiveFailures) return stop('too_many_failures');
      schedule(backoff(policy.intervalMs, failures));
    }
  }

  schedule(0);
  return () => stop('unmounted');
}

function backoff(base: number, failures: number) {
  const jitter = Math.floor(Math.random() * 250);
  return Math.min(base * 2 ** failures + jitter, 60_000);
}

function toHttpStatus(error: unknown): number | undefined {
  if (typeof error === 'object' && error !== null && 'status' in error) {
    return Number((error as { status: unknown }).status);
  }
}

The shape matters:

context snapshot -> request -> latest context reconciliation -> controlled continuation

8. Why reconcile after response?

The request was valid when started. The render is invalid when completed.

That is why late responses need auth-epoch checks.


9. React hook pattern

type JobStatus =
  | { state: 'queued' }
  | { state: 'running'; percent?: number }
  | { state: 'complete'; outputId: string }
  | { state: 'failed'; reason: string }
  | { state: 'cancelled' };

export function useJobPolling(jobId: string, tenantId: string) {
  const auth = useAuthSnapshot();
  const can = useCan();
  const [status, setStatus] = React.useState<JobStatus | null>(null);
  const [stoppedReason, setStoppedReason] = React.useState<string | null>(null);

  React.useEffect(() => {
    if (auth.status !== 'authenticated') return;

    const allowed = can({
      action: 'job.read_status',
      resource: { type: 'job', id: jobId, tenantId },
    });

    if (!allowed.allowed) {
      setStoppedReason(allowed.reason.code);
      return;
    }

    return startAuthAwarePolling<JobStatus>(
      {
        name: `job:${jobId}`,
        getContext: () => {
          const latest = getAuthSnapshot();
          if (latest.status !== 'authenticated') return null;
          return {
            authEpoch: latest.authEpoch,
            tenantId: latest.tenantId,
            permissionVersion: latest.permissionVersion,
          };
        },
        isEligible: (ctx) => ctx.tenantId === tenantId,
        run: (_ctx, signal) => api.get(`/api/jobs/${jobId}`, { signal }),
        onResult: setStatus,
        onStop: setStoppedReason,
      },
      {
        intervalMs: 3000,
        hiddenIntervalMs: 15000,
        stopOnUnauthorized: true,
        stopOnForbidden: true,
        maxConsecutiveFailures: 5,
      }
    );
  }, [auth.status, auth.authEpoch, auth.tenantId, auth.permissionVersion, jobId, tenantId]);

  return { status, stoppedReason };
}

The dependency on authEpoch is intentional. A login/logout/session replacement should deterministically restart or stop the loop.


10. TanStack Query polling pattern

Bad:

useQuery({
  queryKey: ['job', jobId],
  queryFn: () => api.get(`/api/jobs/${jobId}`),
  refetchInterval: 3000,
});

Better:

function useJobStatus(jobId: string) {
  const auth = useAuthSnapshot();

  return useQuery({
    queryKey: [
      'tenant', auth.tenantId,
      'authEpoch', auth.authEpoch,
      'permissionVersion', auth.permissionVersion,
      'job', jobId,
    ],
    enabled: auth.status === 'authenticated',
    queryFn: ({ signal }) => api.get(`/api/jobs/${jobId}`, { signal }),
    refetchInterval: (query) => {
      const data = query.state.data as JobStatus | undefined;
      if (!data) return 3000;
      if (data.state === 'complete' || data.state === 'failed' || data.state === 'cancelled') return false;
      if (document.visibilityState === 'hidden') return 15000;
      return 3000;
    },
    retry: (failureCount, error) => {
      const status = toHttpStatus(error);
      if (status === 401 || status === 403 || status === 404) return false;
      return failureCount < 3;
    },
  });
}

Authenticated query keys should include the security context that makes the data valid.


11. Refresh storms

Refresh storm sequence:

A simple coordinator:

class RefreshCoordinator {
  private inFlight: Promise<void> | null = null;

  refreshOnce(refresh: () => Promise<void>) {
    if (!this.inFlight) {
      this.inFlight = refresh().finally(() => {
        this.inFlight = null;
      });
    }
    return this.inFlight;
  }
}

Replay policy matters:

Request kindReplay after refresh?
idempotent status GETusually yes
notification summary GETusually yes
form mutation POSTonly with idempotency and explicit policy
payment/irreversible commandno automatic replay
upload chunkdepends on resumable protocol

12. Logout and tenant switch registry

Without a registry, background work becomes invisible.

class PollingRegistry {
  private stops = new Map<string, () => void>();

  register(key: string, stop: () => void) {
    this.stops.set(key, stop);
    return () => this.stops.delete(key);
  }

  stopAll(reason: string) {
    for (const stop of this.stops.values()) stop();
    this.stops.clear();
    console.info('polling_stopped', { reason });
  }
}

Use it from auth events:

authEvents.subscribe((event) => {
  if (event.type === 'LOGOUT') {
    pollingRegistry.stopAll('logout');
    queryClient.clear();
  }

  if (event.type === 'TENANT_SWITCH') {
    pollingRegistry.stopAll('tenant_switch');
    queryClient.removeQueries({
      predicate: (query) => query.queryKey.includes(event.fromTenantId),
    });
  }
});

13. Permission drift during long jobs

There are three common choices.

A. Snapshot permission at job start

If user could start job, job may complete and result remains available to that user.

Good for low-risk reports. Risky for sensitive data.

B. Recheck permission at result access

User may start job, but must still be allowed to download result.

Good for sensitive exports and regulated data.

C. Cancel/quarantine job on permission revocation

Revocation invalidates running job.

Good for incident containment and high-risk systems.

React should not guess the policy. The backend should expose a clear status/reason contract.


14. Status response contract

type JobPollResponse =
  | {
      kind: 'job_status';
      jobId: string;
      tenantId: string;
      status: 'queued' | 'running' | 'complete' | 'failed' | 'cancelled';
      progress?: { completed: number; total: number };
      nextPollAfterMs?: number;
    }
  | {
      kind: 'access_denied';
      code: 'JOB_PERMISSION_REVOKED' | 'TENANT_MISMATCH' | 'JOB_NOT_VISIBLE';
      correlationId: string;
    }
  | {
      kind: 'reauth_required';
      code: 'SESSION_EXPIRED' | 'STEP_UP_REQUIRED';
      returnTo: string;
    };

The server may return nextPollAfterMs because it often knows the job queue state better than the browser.


15. Status code taxonomy

StatusMeaningClient behavior
200status visiblerender if context still current
202accepted/runningcontinue with policy delay
304unchangedkeep current projection if context valid
400invalid requeststop and log developer error
401unauthenticated/expiredrefresh once or stop
403forbiddenstop and invalidate permission cache
404not found/not visiblestop; do not reveal distinction unless policy allows
409state conflictrefetch parent/workflow state
410gone/expired artifactstop and show expired state
423lockedcontinue only if lock is visible to user
429rate limitedback off using Retry-After if present
5xxserver failurebounded retry; no auth mutation

Do not treat all errors as “try again forever”.


16. Autosave is a background mutation

Autosave writes data silently.

Bad:

setInterval(() => saveDraft(formState), 5000);

Safer autosave policy:

authenticated only
current tenant only
current field-level write permission only
idempotency/versioning required
abort on logout/tenant switch
stop on 401/403
server enforces permission again

Sketch:

async function autosave(payload: DraftPatch, signal: AbortSignal) {
  const permission = permissionStore.can({
    action: 'case.update_draft',
    resource: { type: 'case', id: payload.caseId },
  });

  if (!permission.allowed) throw new ForbiddenError(permission.reason.code);

  return api.patch(`/api/cases/${payload.caseId}/draft`, payload, {
    signal,
    headers: { 'Idempotency-Key': stableAutosaveKey(payload) },
  });
}

Autosave is not harmless because it is quiet.


17. Upload/download progress

A secure upload lifecycle:

Checks exist at every stage:

StageRequirement
create intentcan attach file to resource
upload bytessigned URL bound to intent and expiry
finalizesame actor/session/tenant or delegated rule
poll statuscan view upload status
access final filecan view final object
delete/cancelcan manage attachment

For sensitive systems, logout should abort unfinished uploads and invalidate upload intent.


18. Offline replay warning

Offline queues are background mutations with a time gap.

A queued operation needs metadata:

type QueuedOperation = {
  operationId: string;
  operationType: string;
  resource: { type: string; id: string; tenantId: string };
  createdByUserId: string;
  createdUnderSessionId: string;
  createdUnderPermissionVersion: string;
  expiresAt: string;
  payloadHash: string;
};

Before replay:

session still valid?
user still same?
tenant still same?
permission still valid?
workflow state still allows operation?
queued operation not expired?

Do not replay privileged mutations only because they were allowed earlier.


19. Multi-tab coordination

One user with five tabs can create five pollers.

Options:

StrategyTrade-off
all tabs pollsimple but wasteful/storm-prone
visible tabs pollbetter but still multiple windows
leader tab pollsefficient but coordination complexity
server pushefficient but needs realtime authorization

Leader rules:

leader scoped by tenant and auth epoch
heartbeat expires quickly
logout releases leadership
tenant switch releases leadership
followers discard old-epoch results

This is not always required. It is useful for expensive/high-frequency/sensitive polling.


20. Observability

Track background auth behavior explicitly.

MetricWhy it matters
active pollers per sessiondetects leaks
poll requests per userdetects storms
polling 401 rateexpiry/refresh issue
polling 403 ratestale permissions or bad projection
refresh collapse countsingle-flight effectiveness
aborted requests on logoutlifecycle hygiene
stale response discarded countrace detection
hidden-tab polling ratepolicy review
tenant mismatch discard counttenant switch defects

Structured event:

{
  "event": "background_poll_stopped",
  "poller": "job-status",
  "reason": "tenant_changed",
  "authEpoch": 42,
  "tenantId": "ten_A",
  "correlationId": "req_abc"
}

Never log raw tokens, session IDs, or sensitive job payloads.


21. Testing matrix

ScenarioExpected behavior
logout while poll request in-flightrequest aborted or late response discarded
session expires during pollingone refresh attempt; no redirect storm
refresh failspollers stop; auth becomes expired/revoked
permission revoked mid-jobpolling stops or returns safe denial
tenant switch during pollold response discarded; cache cleared
tab hiddenpolling pauses/slows according to policy
tab visible againsession revalidated before stale data shown
server returns 429backoff applied
server returns 403stop + permission invalidation
job completes after logoutno toast/result shown
offline then onlineauth revalidated before replay
multiple tabsno duplicated refresh storm
worker finishes after logoutresult discarded by auth epoch

Example test:

it('discards job status from old auth epoch', async () => {
  const auth = createAuthStore({ authEpoch: 10, tenantId: 'A' });
  const results: JobStatus[] = [];
  const request = deferred<JobStatus>();

  const stop = startAuthAwarePolling<JobStatus>(
    {
      name: 'job:test',
      getContext: () => auth.snapshot(),
      isEligible: () => true,
      run: () => request.promise,
      onResult: (result) => results.push(result),
    },
    { intervalMs: 1, stopOnForbidden: true, stopOnUnauthorized: true, maxConsecutiveFailures: 1 }
  );

  auth.logout();
  request.resolve({ state: 'complete', outputId: 'out_1' });
  await flushPromises();

  expect(results).toEqual([]);
  stop();
});

22. Anti-pattern catalog

Raw interval in component

React.useEffect(() => {
  const id = setInterval(load, 5000);
  return () => clearInterval(id);
}, []);

Missing: auth context, tenant, permission, abort, visibility, error taxonomy, refresh coordination, and late response discard.

Retrying 403

403 is an authorization decision. Stop and invalidate projection.

Polling after logout because “it will return 401”

That wastes resources and creates noisy security telemetry.

Global query keys

['notifications']

should usually include tenant/auth/permission scope.

Background output toast after logout

Toasts are UI memory. Bind them to auth epoch and tenant.


23. Production checklist

  • Every poller has an owner and lifecycle.
  • Every poller is scoped by auth epoch and tenant.
  • Logout cancels timers and in-flight requests.
  • Tenant switch cancels tenant-scoped work.
  • Query keys include tenant and relevant auth/permission version.
  • 401 does not create redirect storms.
  • Refresh is single-flight.
  • 403 stops or invalidates permission projection.
  • Hidden tab behavior is intentional.
  • Offline replay revalidates auth and permission.
  • Long-running job output is separately authorized.
  • Late responses from old auth context are discarded.
  • Sensitive notification counts do not leak hidden resources.
  • Worker/service-worker background work receives logout/tenant-switch signals.
  • Metrics exist for active pollers, stops, aborts, stale discards, and auth failures.

24. Final mental model

A background task is not exempt from auth because it is invisible.

It is more dangerous because it is invisible.

The rule:

Every background request must prove it still belongs to the current authenticated context before it starts, while it runs, and before its result mutates UI state.

Start under current auth. Finish under current auth. Render only under current auth.


References

Lesson Recap

You just completed lesson 67 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.