When a Valid Session Becomes Stale
How I used a small Firestore signal to keep open PufferPOS sessions aligned with permissions, billing, and organization state.
A cashier can leave PufferPOS open for hours.
While that screen stays open, an owner can change permissions from another device. A scheduled job can expire a subscription. A manager can lose access to an organization. The same user may have the dashboard open in another tab.
The browser does not know any of that happened.
The session may still be technically valid while the product state around it is no longer valid.
That was the real problem.
Not "How do I authenticate the user?"
The better question was:
How do I make every open client react when session-relevant business state changes somewhere else?
The stale session
The naive version sounds safe.
Check permissions when the user signs in. Check again on navigation. Check every protected server action before it does anything sensitive.
I still do all of that.
The server must remain strict about the user, organization, role, permissions, billing state, and requested action.
But server enforcement does not make the open interface fresh.
If access is revoked, the next protected request can fail safely while the cashier still sees the old POS. If billing expires, the backend can block an operation while the dashboard continues presenting an outdated state. The user learns what changed through an error.
That is secure enough to protect the action.
It is not good product behavior.
A signal, not a session
I did not need a large event system.
I needed a small invalidation signal.
Owner, admin, or scheduled job changes business state
|
Server writes sessionRefreshSignals/{uid}
|
Every open client for that user hears the change
|
Client refreshes the NextAuth session
|
UI refreshes or moves the user out of invalid contextThe Firestore document is not the session and never becomes the source of truth.
It carries no permissions object, billing snapshot, member profile, or private organization data.
It says one thing:
Something relevant changed. Ask the server for the current truth.
The payload
I use one document per user at sessionRefreshSignals/{uid}.
The payload stays small:
type SessionRefreshSignal = {
version: number
reason:
| "permissions_changed"
| "organization_revoked"
| "subscription_activated"
| "organization_settings_changed"
| "billing_status_changed"
orgId: string | null
updatedBy: string
updatedAt: Timestamp
}version is the deduplication key. Every server write increments it.
reason lets the client distinguish a normal refresh from a change that needs different product behavior, especially revocation.
Server side
The server helper builds the signal with Firestore atomic values:
import "server-only"
import { admin_db } from "@/firebase/admin"
import { FieldValue } from "firebase-admin/firestore"
export type SessionRefreshReason =
| "permissions_changed"
| "organization_revoked"
| "subscription_activated"
| "organization_settings_changed"
| "billing_status_changed"
const COLLECTION = "sessionRefreshSignals"
function signalsEnabled() {
return process.env.NEXT_PUBLIC_ENABLE_SESSION_REFRESH_SIGNALS === "true"
}
function signalPayload({
reason,
orgId,
updatedBy
}: {
reason: SessionRefreshReason
orgId: string | null
updatedBy: string
}) {
return {
version: FieldValue.increment(1),
reason,
orgId,
updatedBy,
updatedAt: FieldValue.serverTimestamp()
}
}
export async function writeSessionRefreshSignal({
uid,
reason,
orgId,
updatedBy
}: {
uid: string
reason: SessionRefreshReason
orgId: string | null
updatedBy: string
}) {
if (!signalsEnabled()) return
await admin_db
.collection(COLLECTION)
.doc(uid)
.set(signalPayload({ reason, orgId, updatedBy }), { merge: true })
}The feature flag is intentional. This code touches sessions, routing, and every authenticated screen. I wanted a way to turn it off without ripping it out.
Some events affect one user. Others, such as an organization billing change, affect every active member. The multi-user helper writes the same payload in batches of 450.
I leave margin below Firestore's batch limit because I would rather have boring batch behavior than debug a billing update that tried to be clever at the edge.
Client side
The listener lives near the authenticated app shell.
Its job is to subscribe, reject bad signals, deduplicate versions, refresh the session, and choose the correct route behavior.
This is the core shape:
useEffect(() => {
if (!ENABLED || status !== "authenticated" || !session?.user?.uid) {
return
}
const uid = session.user.uid
const handledKey = "session-refresh:lastVersion:" + uid
const unsubscribe = onSnapshot(
doc(db, COLLECTION, uid),
(snapshot) => {
if (!snapshot.exists()) return
const signal = snapshot.data()
const version = Number(signal.version ?? 0)
const lastHandled = Number(
window.sessionStorage.getItem(handledKey) ?? 0
)
if (!Number.isFinite(version) || version <= lastHandled) return
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(async () => {
if (refreshInFlightRef.current) return
refreshInFlightRef.current = true
try {
const activeOrgId =
session.user.activeOrg?.id ?? session.user.lastActiveOrgId
const refreshed = await update(
activeOrgId ? { activeOrgId } : undefined
)
window.sessionStorage.setItem(handledKey, String(version))
if (signal.reason === "organization_revoked") {
router.replace("/select-org")
return
}
if (!refreshed?.user?.activeOrg?.id) {
const hasOrg =
(refreshed?.user?.orgs?.length ?? 0) > 0 ||
(refreshed?.user?.revokedOrgs?.length ?? 0) > 0
router.replace(hasOrg ? "/select-org" : "/onboarding")
return
}
router.refresh()
} finally {
refreshInFlightRef.current = false
}
}, 250)
}
)
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
unsubscribe()
}
}, [session, status, update, router])The important work is not the listener itself.
It is the few small guards around it.
The small guards
Version per tab. Firestore sends the current document when a listener mounts. Without deduplication, navigation could refresh the session again for an old signal.
I store the last handled version in sessionStorage, not localStorage. Each tab should react independently because the user may have the POS in one tab and the dashboard in another.
A short debounce. One admin action may update permissions, settings, and billing-derived state close together. Waiting 250 milliseconds collapses obvious bursts without making the interface feel slow.
One refresh at a time. Debouncing does not stop a second signal from arriving while update() is still running. An in-flight ref keeps refreshes from fighting each other.
Cleanup. The authenticated shell lives a long time. The listener still has to clear its timer and unsubscribe when its context changes.
None of these decisions are exciting.
That is why I like them. Session infrastructure should be boring.
Revocation is different
Most signals can end with router.refresh().
Permission changes may hide or reveal controls. Billing changes may block a flow. Organization settings may change server-rendered data.
Revocation changes the user's location in the product.
If the active organization is no longer valid, I do not want the user to stay inside it and discover the change one failed request at a time. The client moves them to organization selection, or onboarding if no valid organization remains.
This is where the feature stops being only synchronization.
It becomes product state management.
The edge cases
The feature exists because of the edges:
- one user can have several tabs or devices open
- one organization event can affect many users
- a scheduled job can change state without an active admin
- the current organization can disappear during a session
- a listener can mount after the latest signal already exists
- several changes can happen close together
- stale UI can be secure and still confuse the user
That last distinction matters most.
In a POS, people do not refresh like developers do. They keep screens open and expect the product to tell the truth.
What this does not replace
The signal does not authorize anything.
Every protected server operation still checks the current user, organization, role, billing state, input, and permission.
The listener only improves freshness and routing.
I think of the layers this way:
- the server protects the system
- the signal wakes active clients
- the refreshed session carries current state
- the UI moves the user somewhere valid
If the Firestore listener became the security boundary, the design would be wrong.
What I would add
The current version is intentionally small.
If it grows, I would add:
- a lightweight event log for who triggered a refresh and why
- typed domain events that separate what happened from who needs a signal
- careful user messages for changes that deserve interruption
- a better fallback when one branch is revoked but another remains available
- observability around signal delivery and session refresh failures
I would keep the client document small.
Debugging infrastructure can grow around the signal without turning the signal into a second session store.
The system model
This started as an authentication problem.
It was really a timing problem between business state and open interfaces.
The final model is:
Server enforces.
Signal notifies.
Session refreshes.
UI catches up.Remove the server check and security breaks.
Remove the signal and the interface goes stale.
Remove the routing decision and the user stays inside a context that no longer exists.
All four layers matter, but only one of them is the source of truth.