Horizontal scaling
One replica of a GoFastr app is self-contained: sessions, rate limits,
cron, queues, and live UI updates all work with no extra setup because
their default state lives in the process. The moment you run a second
replica behind a load balancer, every one of those defaults needs a
shared backend or a deliberate single-runner strategy. This page lists
what breaks, why, and the replica-safe alternative for each one.
Summary
The database is the only state the replicas share. Anything that
defaults to process memory must either move into the DB (or another
shared store) or run on exactly one replica.
What is process-local by default
| Subsystem | Default | Two-replica symptom | Replica-safe fix |
|---|---|---|---|
| Auth sessions | in-memory MemorySessionStore | login on A, logged-out on B; all sessions lost on restart | auth.NewEntitySessionStore(db, "sessions") |
| 2FA enrollment | in-memory MemoryTwoFAStore | worse than scaling: a restart silently reverts 2FA accounts to password-only | auth.NewEntityTwoFAStore(db, "auth_twofa") — the plugin creates the table itself |
| Login rate limits | in-process RateLimiter | attacker gets N attempts per replica; blocks don't propagate | set RateLimiterConfig.Store: auth.NewSQLRateLimitStore(db, "auth_rate_limits") — one budget across replicas, blocks propagate |
framework/cron scheduler | ticks in every process | every replica fires every job | one GOFASTR_ROLE=worker process owns it (see "Serve/worker roles"), or use battery/queue's DBQueue (see below) |
battery/queue in-memory queue + Scheduler | per-process | duplicate jobs, lost jobs on restart | queue.DBQueue — FOR UPDATE SKIP LOCKED makes competing workers safe |
| Live events / SSE / island push | in-process EventBus + island.Manager | an event emitted on A never reaches a browser connected to B | fanout.NewPostgres(dsn, db) (returns an error — check it) then framework.WithFanout(f) — see "SSE across replicas" below |
battery/cache memory backend | per-process | stale reads after another replica writes | cache.NewRedisCache(client), or accept per-replica caching for derived-only data |
| File uploads on local storage | per-replica disk (storage.NewLocalStorage, upload.NewLocalStorage) | upload lands on A, download from B 404s | S3-compatible backend (battery/storage's S3 client), or a shared volume mounted on every replica |
Runtime RBAC grants (access.GrantStore) | in-memory RolePolicy cache per process | editor granted on A still denied on B until B restarts; a revoked code-seeded grant re-appears on peers and on restart | framework.WithGrantStore(store) + framework.WithFanout(...) — grant/revoke publishes a refresh-signal on the gofastr.access lane; every replica re-reads the role's grants from access_grants. A revoke also writes a revocation tombstone to access_grants_revoked, so revokes propagate to peers AND survive replica restarts even for grants declared in code; re-granting via GrantStore.Grant lifts the tombstone |
Auth enforces the first two at boot in production mode (DevMode:
false): both the in-memory session store and the in-memory 2FA
store refuse to boot — a warn-only start lets a broken multi-replica
deployment go unnoticed. Setting AuthConfig.AllowInMemoryStores: true
acknowledges a deliberate single-node deployment: both stores then boot,
and the 2FA store still leaves a WARN trace.
What is already replica-safe
- Migrations — auto-migrate takes a Postgres advisory lock, so N
replicas booting simultaneously run the migration once. - Startup seeds —
RunSeedsandWithSeedhooks acquire a
DISTINCT Postgres advisory lock (separate from migrations) so N
booting replicas never race a seed func. Combined with the
_gofastr_seededledger, an entity'sSeedruns once globally; the
other replicas see the ledger row on their locked turn and skip. A
crashed lock holder's session-level lock is released by Postgres
automatically — no permanent block. (WithSeedhooks have no
ledger, so they serialize-per-boot but still run on every replica —
keep them idempotent.) Exception: a Postgres pool capped at
MaxOpenConns(1)cannot hold the advisory lock (it would deadlock the
seed body), so it skips the lock with a WARN and N such replicas are
NOT coordinated — keep the pool above 1 connection for multi-replica
seed serialization. queue.DBQueue— claims jobs withFOR UPDATE SKIP LOCKED;
competing workers on every replica are the intended topology.- Webhook delivery (
LeasedStore) — leases deliveries so two
replicas don't double-send. - Plain CRUD/API traffic — stateless per request; scale freely.
Serve/worker roles
The first scaling step for a self-hosted app is one web process + one
worker process, same binary — before replicas, before Redis. The role
is picked at deploy time:
<!-- gofastr:compile
import "github.com/DonaldMurillo/gofastr/framework"
var app = framework.NewApp()
-->
app.Start(":8080") // role from GOFASTR_ROLE: all | serve | workerGOFASTR_ROLE=serve ./myapp # full router; no cron/queue/outbox-relayGOFASTR_ROLE=worker ./myapp # cron/queue/outbox-relay; /healthz + /readyz only./myapp # combined (default) — today's behavior
framework.WithRole(framework.RoleServe) overrides the env var; an
invalid value in either fails at construction. The worker's health
endpoints are the same handlers the full router serves, so LB and
orchestrator probes work unchanged. Everything else — auto-migrate,
seeds, plugins, batteries — runs in both roles (migrations hold a lock,
so either process type may boot first). Plain OnStart hooks are
role-agnostic; gate custom background work on app.Role().
Recommended shapes
Two web replicas + one worker. Point sessions at
EntitySessionStore, run the web replicas with GOFASTR_ROLE=serve,
and one GOFASTR_ROLE=worker process that owns the cron scheduler,
the DBQueue workers, and the outbox relay. This is the smallest
shape with no shared-state caveats. Add WithFanout if the web
replicas use SSE push.
Everything everywhere, DB-backed. All replicas run DBQueue
workers (safe by design). For scheduled work, have the schedule
enqueue a DBQueue job instead of doing the work inline — then it
doesn't matter that every replica's scheduler fires, as long as the
job is idempotent or keyed for dedup. Neither scheduler ships a
distributed lock; the queue's claim semantics are the sanctioned
coordination point.
Single node, on purpose. Vertical scaling is underrated. Set
AuthConfig.AllowInMemoryStores: true to let the in-memory stores boot
and skip this whole page until you add a replica. A restart still logs
everyone out and wipes in-memory 2FA enrollment — use the entity-backed
stores anyway if either matters.
SSE across replicas
Server-pushed events flow over an SSE connection to the replica the
browser happened to reach. A write handled by a different replica emits
on its bus and pushes to its island manager, not the one holding
the connection. Two answers, in order of preference:
- Shared fan-out —
framework.WithFanoutbridges the real-time
lane across replicas.framework/fanout.NewPostgres(dsn, db)uses
Postgres LISTEN/NOTIFY (no new infrastructure);core/fanout.NewRedis
adapts a Redis client you bring. Entity_eventsSSE streams and
island push then work from any replica. Read the "Cross-replica
fan-out" section of the events doc first — with a fanout attached,
On/Subscribehandlers fire on every replica, so side-effect
work must move to outbox consumers and derived emits must gate on
event.IsRemote(ctx). - Poll instead. For passive freshness — a dashboard, a counter, a
status pill —data-fui-pollre-fetches on an interval from any
replica and needs no fanout at all. Reserve SSE push for semantics
that need the connection: presence, collaborative editing,
sub-second updates. See Reactivity model for the
full ladder.
Sessions
The uihost session is an HMAC-signed token, not a server-side record.
A token issued by one replica verifies on any other replica that
shares the signing secret, so any replica can serve any request.
- Set the secret in production —
framework.WithSecret(secret)in
code, or theGOFASTR_SECRETenvironment variable. Either lands the
same key on every replica. - One replica, no secret configured. The framework mints an
ephemeral boot secret at startup. Sessions work, but they roll over
on every restart because the next boot mints a new one. Fine for
single-node; wrong the moment a second replica is on the table. - Fanout without a secret fails at boot. A multi-replica deploy
that wiredWithFanoutbut forgotGOFASTR_SECRET(orWithSecret)
refuses to start, with an error naming both. This is deliberate —
silent token mismatch in production is worse than a loud boot
failure.
Sticky sessions are not part of the contract. A token is portable;
route the request to whichever replica the load balancer picks.
Checklist before adding the second replica
- [ ] Sessions on
EntitySessionStore(or another sharedSessionStore). - [ ] 2FA store durable (if the 2FA plugin is enabled).
- [ ] Cron scheduler runs on exactly one process (
GOFASTR_ROLE=worker), or jobs moved toDBQueue. - [ ] Queue is
DBQueue, not the in-memory variant. - [ ] Rate limits on a shared store (
RateLimiterConfig.Store: auth.NewSQLRateLimitStore(db, "auth_rate_limits")), or enforced at
the ingress. - [ ] SSE push crosses replicas:
WithFanoutattached (and side-effect
handlers moved to outbox consumers), or usedata-fui-pollfor
passive freshness that needs no fanout. - [ ]
GOFASTR_SECRETset (orframework.WithSecretin code) so the
HMAC-signed uihost session token verifies on every replica.
Required withWithFanout— the app refuses to boot otherwise. - [ ] Runtime RBAC grants propagate:
WithGrantStoreattached when
access.GrantStoreis in use, so grant/revoke reaches every
replica'sRolePolicywithout a restart. A revoke persists a
revocation tombstone (access_grants_revoked), so it propagates to
peers and survives replica restarts even for grants declared in
code; re-granting lifts the tombstone. - [ ] Cache backend shared (Redis) if cached data must be coherent across replicas.
- [ ]
AuthConfig.AllowInMemoryStoresremoved — the boot warning is
your regression test for the first two items.
See also
- UI capability map contrasts reconstructable and affinity-bound islands by product job.
- Events and SSE defines cross-replica fanout semantics.
- Presence documents lossy, self-healing roster aggregation.
Common mistakes
- Scaling to two replicas with default sessions. Users get randomly
logged out depending on which replica the LB picks. The boot WARN
about the in-memory session store is telling you this will happen —
don't silence it withAllowInMemoryStoreswhile running N > 1. - Setting
AllowInMemoryStores: true"to clean up the logs" and
then scaling later. The flag is an assertion about your topology, not
a log filter; remove it the moment a second replica is on the table. - Running
framework/cronon every replica because each job
"checks if it already ran" against the DB. That check is a race, not
a lock. Move the work toDBQueueor pin the scheduler to one process. - Relying on SSE push without fanout. Everything appears to work
in staging (one replica) and half of live updates silently vanish in
production. WireWithFanoutand setGOFASTR_SECRETbefore adding
the second replica, or pick polling for surfaces that don't need
push. - Treating the in-process login rate limit as a security boundary at
N replicas. Without a shared store the budget multiplies by replica
count and blocks don't propagate. SetRateLimiterConfig.Store(one
SQLRateLimitStorecan back every auth limiter — keys are namespaced
per limiter scope) or enforce hard limits at the ingress.
See Deployment for the single-replica production checklist
and Job queue for DBQueue worker sizing.