PATTERN Cited by 1 source
Session cookie for read-your-writes¶
Pattern¶
When the database architecture is
read-write split (writes to primary, reads to replica pool) and
the replica pool is eventually consistent, preserve
read-your-writes
consistency by setting a per-user session cookie on every
write and routing reads to primary for Δ seconds while that
cookie is fresh.
"we can also tell Rails to read from our primary if the user recently wrote to the database. This protects our users from ever reading stale data due to replication lag … After each write, it will set a cookie that will send all reads to the primary for 2 seconds, allowing users to read their own writes." (Source: sources/2026-04-21-planetscale-introducing-planetscale-portals-read-only-regions.)
The concept sibling — concepts/session-cookie-read-your-writes-window — covers the properties of the window itself; this pattern page is the applied recipe.
When to apply¶
- Architecture already has read-write splitting. This pattern is the RYW-preserving companion to patterns/read-replicas-for-read-scaling or patterns/per-region-read-replica-routing.
- User-facing interactive app. RYW is a perceived-correctness property a human user notices. Agent-to-agent / server-to-server traffic usually doesn't care.
- Cookies are in the request path anyway. Rails, Django, Laravel, Express-with-sessions, etc. — anywhere a per-user session state is already threaded through every request. For stateless-token auth (JWT-only APIs), the same mechanism works but the pin must live in the user's client state (localStorage, header) and be echoed back, which is more plumbing.
Mechanics¶
- Choose
Δ. Must exceed p99 replication lag across the replica pool. For same-region replicas, 1–2 s suffices. For regional read replicas with cross-region lag, 5–10 s is safer. GraphΔvs. replica-lag p99 to verify. - On every write (POST/PATCH/DELETE in HTTP terms, or any transaction that commits changes), set a cookie with the current timestamp.
- On every read, check the cookie. If
now() - t_cookie < Δ, route the read to primary. Else route to the replica pool. - Surface a telemetry flag — per-request, was this request routed to primary because of RYW, or because of replica- selection policy? This lets you see the RYW-tax rate (% of reads forced to primary) and debug the mechanism in production.
Rails-specific invocation¶
# config/environments/production.rb
config.active_record.database_selector = { delay: 2.seconds }
config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
connects_to database: { writing: :primary, reading: :primary_replica }
end
The DatabaseSelector middleware handles cookie set on writes,
cookie read on reads, and routing. Default delay is 2 seconds;
pick a larger value if your replica pool is geographically
distributed.
Django-specific invocation¶
# settings.py
DATABASES = {'default': {...}, 'replica': {...}}
DATABASE_ROUTERS = ['app.routers.RYWRouter']
# app/routers.py
class RYWRouter:
def db_for_read(self, model, **hints):
req = hints.get('request')
if req and _cookie_fresh(req, delta=timedelta(seconds=2)):
return 'default'
return 'replica'
def db_for_write(self, model, **hints):
req = hints.get('request')
if req: _set_cookie(req)
return 'default'
Laravel-specific note¶
Laravel's built-in 'sticky' => true read/write config is a
weaker form: sticky reads stay on the write connection for the
rest of the current request, not across subsequent requests in
the same session. For true RYW across page reloads, add a session
cookie check in addition to the sticky flag.
Trade-offs¶
| Dimension | Effect |
|---|---|
| RYW correctness | ✓ (within Δ) |
| In-region read latency for the writing user | paid primary-region RTT for Δ seconds (possibly cross-region) |
| In-region read latency for other users | unchanged — they read from replica |
| Plumbing cost | ~20 lines of middleware |
| Granularity | coarse — all reads for the user pin, not just reads that depend on the write |
Failure mode if Δ too small |
ghost-reads during replication stalls |
Failure mode if Δ too large |
unnecessary primary reads — more load on primary, more in-region RTT for the user |
Why not a token-based scheme instead?¶
Token-based RYW (LSN, GTID, MongoDB $clusterTime) is more
precise — only reads that actually depend on the write pay the
primary-read tax, and the check is "has replica R caught up to LSN
X?" rather than a coarse time window. But it requires:
- Write returns an opaque position token to the client.
- Every subsequent read sends the token.
- Router checks each replica's current position against the token before routing — or forwards to primary if no replica is caught up.
For most interactive web apps, the session-cookie coarseness is fine and the token plumbing isn't worth it. Choose tokens for agent pipelines, high-QPS read paths, or platforms that already surface LSN / GTID cheaply.
Seen in¶
- sources/2026-04-21-planetscale-introducing-planetscale-portals-read-only-regions
— canonical launch source for the Rails
DatabaseSelectorinvocation + 2-second default + cross-region context.
Related¶
- concepts/read-your-writes-consistency
- concepts/session-cookie-read-your-writes-window
- concepts/replication-lag
- concepts/read-write-splitting
- concepts/regional-read-replica
- patterns/per-region-read-replica-routing
- patterns/read-replicas-for-read-scaling
- systems/ruby-on-rails
- systems/planetscale-portals