Expiring offline access token - difficulties with background jobs:

I am using @shopify/shopify-app-remix": “^4.2.0” package.
Shopify’s library automatically refreshes the offline access token(if required) when you call any of the following:

  1. Background jobs: await unauthenticated.admin(shop);
  2. App proxy: await authenticate.public.appProxy(request);
  3. Webhooks: await authenticate.webhook(request);

we have an issue when offline access token is refreshed but some of the bg jobs are still using expired offline token and finally it ends up having unauthorized request to admin gql api.

Any best practices to handle this situation.

Yeah this bites in production. The refresh inside unauthenticated.admin(shop) only updates the session row in your SessionStorage, but any worker that grabbed a session before the refresh is still holding the old object in memory and will keep using the stale token until it requeues.

Couple things that helped us. First, never pass the session object across job boundaries. Pass shop only, then call unauthenticated.admin(shop) fresh inside the job handler so it always reads the latest token from storage. Second, wrap GQL calls with a 401 retry that forces a refetch from SessionStorage on failure, don’t trust the in-memory session. Third, if you have long-running jobs that loop, re-resolve the session every N minutes inside the loop.

The library will refresh automatically, but only when it’s the entry point. If you cache the admin client at process start you’re back to square one.

Are your bg jobs long-running workers, or short queue handlers that pick up one job at a time?

Hey @crpatel 1)So Understand the real problem

Shopify is working correctly.

What happens is:

Shopify refreshes the offline token
But your background job is still using an old in-memory session/client

So your job is “out of sync” with the latest token.

  1. Pass ONLY shop into jobs

When you enqueue a job send ONLY:

shop

Do not pass:
session
admin client
authenticated context

Because these can expire or change after token refresh.

  1. Re-create Admin client inside every job

Inside your worker always do:

await unauthenticated.admin(shop)

This is important Because, This pulls the latest token from storage
Ensures you never use stale credentials
Keeps every job fresh and safe

  1. Never cache Admin client
    Avoid:
    global admin client
    singleton admin instance
    storing admin in module variables
    keeping admin in closures

Because cached admin is equal to old token in memory
Even if Shopify already refreshed it.

5)Treat every job as “stateless”

Each background job should behave like:

I know nothing I will rebuild everything fresh

So every job:

gets shop
rebuilds admin
runs API call
6) Add 401 retry handling it’s safety layer

Sometimes race conditions still happen.

So if request fails:

So You can Do this:
Catch 401 Unauthorized
Re-run:
unauthenticated.admin(shop)
Retry request once

7)For long-running workers

If your worker runs continuously:

You must:
Not reuse admin between jobs
Not store session globally
Re-create admin for every task

Don’t pass things like:

session
admin client
access token
authenticated objects
Because the shop stays the same but sessions and tokens can expire or change.
If you pass them into jobs they can become outdated and cause errors.
I hope it’s help You

Thank You !

Hey there @crpatel,

This sounds like a classic race condition where one process is updating the database with the new token while another concurrent job is still grabbing the “old” one from your session storage. It’s a known pain point when multiple background tasks fire at the exact same time during a refresh window.

The most practical way to handle this is to implement a basic retry logic specifically for your background jobs. If you hit a 401 Unauthorized error, don’t fail immediately, instead wait a second and call unauthenticated.admin(shop) again to force a fresh pull from your database.

Also, double-check your Session Storage implementation (like Prisma or Redis). Make sure your “load” and “store” functions aren’t caching the session object in a way that prevents concurrent threads from seeing the update. If you’re using a database, ensuring your upsert logic is truly atomic will help make sure that once the token is refreshed, every other job sees the new one instantly!

Hope this helps,

thank @lumine :smiley:
We have mix of both. some of them are long(it depends on number of products selected), some of them are very short. and they are of different nature. some jobs get triggered on user action, some are cron, some are delayed jobs(they collect webhooks for two minutes and the process it).

We will try this solution in our app, wrapping GQL for 401 failure seems perfect solutions :+1:

Hello @crpatel

Not sharing any in-memory session objects between background jobs is the best. Instead, you should always resolve a new offline session per job via session storage, and reauthenticate with shopify admin for each execution of a task. That way the token refresh logic will be invoked at run time not queue time. You can even add retry logic for 401 responses, refresh your session and retry the GraphQL request one more time. For queued jobs, pass only the shop and the job payload — never the session itself, so you don’t use stale tokens.