How to Manage Concurrent Browser Sessions with Nginx and Lua
Learn where OpenResty session state belongs, when to lock concurrent updates, and why local shared memory does not coordinate multiple servers.
To manage concurrent requests for the same browser session in Nginx with Lua, put session state in a store whose scope matches your deployment, then serialize any non-atomic read-modify-write update that must not overlap. In OpenResty/ngx_lua, lua_shared_dict shares data among workers in one Nginx server instance, and lua-resty-lock can lock a session key across those workers. Neither creates a cluster-wide session system: for multiple hosts, use a shared external session store or coordination mechanism with documented cross-instance semantics.
Plain NGINX does not provide these Lua APIs by itself. The examples below assume OpenResty or NGINX built with a compatible ngx_lua module and the lua-resty-lock library. Verify API behavior, package versions, build options, and phase constraints for your deployed release before using the configuration.
1. Choose the state scope and concurrency policy
First decide what the state represents and which requests must coordinate. A lock protects correctness for a critical update; a concurrency limit controls request load. They solve different problems.
| Mechanism | Scope | Useful for | Boundary |
|---|---|---|---|
| Lua module variable | One worker process | Read-only data or worker-local state | Not shared among workers. Mutable data can be unsafe if execution yields during an operation. |
lua_shared_dict |
Workers in one Nginx server instance | Shared counters, cache entries, and state accessible to local workers | Not shared across hosts. It has atomic dictionary operations such as incr, but a sequence of operations is not automatically atomic. |
lua-resty-lock |
Workers in one Nginx server instance, using shared memory | Serializing a short critical section by key | A mutex, not a session store; a local lock cannot coordinate other hosts. |
| External session store or coordination service | As defined by that service and its configuration | Sessions used by requests routed to multiple application instances | Confirm the backend’s consistency, failure, and locking semantics. |
limit_conn or resty.limit.conn |
According to the configured NGINX shared-memory zone or limiter | Restricting simultaneous requests for a key | Admission control; does not by itself make a session update atomic. |
Use the narrowest scope that still meets the deployment’s correctness needs. If requests for one browser can reach multiple Nginx hosts, local shared memory is insufficient even if every host runs identical configuration.
2. Configure a shared dictionary and lock zone
Declare the shared dictionary in the http context. A dictionary is shared among workers in that Nginx instance. Choose its size from the expected workload and validate memory use and eviction behavior in your environment; there is no universal size.
http {
lua_shared_dict browser_sessions 16m;
lua_shared_dict session_locks 2m;
server {
listen 8080;
location = /session/increment {
content_by_lua_file /etc/nginx/lua/session_increment.lua;
}
}
}
Install or otherwise make lua-resty-lock available to the Lua runtime used by OpenResty. The library uses shared memory to coordinate workers in the current server instance. The lock zone must be a shared dictionary available to the library.
3. Serialize a read-modify-write update
Use a stable, validated session identifier as the lock key. Do not log raw session credentials. After acquiring the lock, read the current value again: it may have changed while this request waited. Keep the protected section short and release the lock on every success and failure path.
The following request handler illustrates the sequence using a numeric counter in the shared dictionary. It is runnable as an OpenResty content handler when the library is installed and the preceding zones are configured. A real session record will need application-specific serialization, validation, expiry, and error handling.
-- /etc/nginx/lua/session_increment.lua
local session_id = ngx.var.arg_sid
-- Example validation only. Prefer a high-entropy opaque session ID supplied
-- through a secure cookie, and never accept arbitrary unbounded dictionary keys.
if not session_id or not session_id:match("^[%w_-]+$") or #session_id > 128 then
return ngx.exit(ngx.HTTP_BAD_REQUEST)
end
local sessions = ngx.shared.browser_sessions
local lock_lib = require "resty.lock"
local lock, lock_err = lock_lib:new("session_locks", {
timeout = 1,
exptime = 5,
})
if not lock then
ngx.log(ngx.ERR, "could not create session lock: ", lock_err)
return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
local elapsed, lock_err = lock:lock("session:" .. session_id)
if not elapsed then
if lock_err == "timeout" then
ngx.header["Retry-After"] = "1"
return ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE)
end
ngx.log(ngx.ERR, "could not acquire session lock: ", lock_err)
return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
-- Read after lock acquisition so this request sees the latest value.
local key = "count:" .. session_id
local count = sessions:get(key) or 0
local new_count = count + 1
local ok, set_err = sessions:set(key, new_count, 3600)
-- Always unlock, including when writing fails.
local unlock_ok, unlock_err = lock:unlock()
if not unlock_ok then
ngx.log(ngx.ERR, "could not release session lock: ", unlock_err)
end
if not ok then
ngx.log(ngx.ERR, "could not write session value: ", set_err)
return ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
end
ngx.header.content_type = "application/json"
ngx.say('{"count":', new_count, '}')
This example keeps the critical section short and uses a bounded wait. If your code adds operations that can fail or return early after the lock is acquired, restructure it so every such path releases the lock. For more complex operations, a protected helper with a single cleanup path can make this easier to audit.
Why the second read matters
Suppose two requests both read a count of 4 before either writes. Each computes 5, so one increment is lost. With a per-session lock, the second request waits, then reads the first request’s updated value and writes 6. For a single shared counter, an atomic dictionary operation such as incr may be enough; a lock is useful when the update spans multiple reads, checks, or writes that must remain consistent together.
4. Set lock timeouts and handle contention
lua-resty-lock documents a five-second default wait timeout and a thirty-second default lock-entry expiry. Configure values deliberately rather than relying on defaults. The wait timeout cannot exceed the expiry setting. Use a bounded wait, set expiry above the expected critical-section duration with operational margin, and tune both from observed workload behavior.
- Timeout: choose an application response or an explicit retry policy. Do not continue as if the lock was acquired.
- Other lock errors: log a useful error without exposing session secrets; return an appropriate failure response.
- Unlock: call
unlock()as soon as the protected work ends, including error paths. - Expiry: treat it as a recovery backstop for abandoned entries, not permission to leave a lock held.
- Concurrent light threads: create a separate lock object for each simultaneous lock; lock objects are stateful.
The lock library uses cooperative polling sleeps, so waiting does not block an operating-system thread in the same way as a blocking mutex. Contention still consumes request time and capacity, so keep the critical section short.
5. Use the correct mechanism for traffic limits
If the policy is “allow at most N simultaneous requests for this key,” configure NGINX limit_conn or OpenResty’s resty.limit.conn with a key and shared-memory zone appropriate to that policy. If the policy is “ensure two updates to the same session do not overwrite each other,” use an atomic operation or serialize the critical update. A traffic limiter is not a substitute for session correctness, and a session lock is not a general load-shedding policy.
OpenResty’s lua-resty-limit-traffic includes resty.limit.conn. Check its package documentation and the standard NGINX limit-connection module documentation for configuration details and behavior in the deployed version.
6. Plan for multiple hosts and failures
Shared dictionaries and lua-resty-lock coordinate only within the current Nginx server instance. If a load balancer can send requests for one session to different hosts, choose an external session store or coordination mechanism designed for that topology. Confirm how it handles concurrent writes, expiration, outages, retries, and failover; the correct consistency model depends on the application.
- Worker crash: worker-local module state disappears with that worker. Shared-memory state belongs to the Nginx instance, not a durable database.
- Nginx restart or reload: do not assume local shared-memory session data is durable across lifecycle changes; verify the behavior for the exact deployment and configuration.
- External store outage: define whether requests fail closed, use a safe fallback, or return a retryable error. Avoid silently accepting stale or missing session state.
- Lock expiry during a long operation: another request may acquire the key after expiry while the original work is still running. Keep critical sections short and choose expiry with measured margin.
- Retries: if clients retry after a timeout, make state-changing operations idempotent where possible or use an operation identifier to avoid applying an update twice.
7. Integrate with session libraries carefully
Session libraries can own storage and locking behavior. For example, the lua-resty-openidc package documentation notes that when server-side storage uses locking, a session may still be locked when returned from authenticate, and shows explicitly closing that session. This is version- and backend-specific: verify the lifecycle instructions for the actual library version and storage backend rather than adding a second lock around it without checking.
Session locking does not define your cookie security attributes, CSRF policy, session rotation, or authorization checks. Set those according to the application’s security requirements and the session library’s documented behavior.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
require "resty.lock" fails |
The library is missing from the Lua module path or incompatible with the deployed OpenResty/ngx_lua release. | Install the package for the runtime, check lua_package_path and deployment packaging, and confirm version compatibility. |
| Workers see different values | The value is stored in a Lua module variable, which is worker-local. | Use lua_shared_dict for one-instance worker sharing or an external store for multiple hosts. |
| Updates are still lost | The lock key differs between requests, the read happened before locking, or the update uses an unlocked code path. | Normalize and validate one stable session key; acquire first, then re-read and update under the same lock. |
| Requests time out under load | Lock contention, a long critical section, or an undersized timeout budget. | Reduce work under lock, inspect contention and latency, and tune bounded wait and expiry values from measurements. |
| Lock returns an error or cannot be released | Shared lock dictionary configuration issue, invalid lifecycle handling, or an error in the protected path. | Check zone names and capacity, handle every return path, and log lock errors without session secrets. |
| Locking works on one host but not across hosts | Local shared memory is being mistaken for a distributed lock. | Use a shared backend or coordination service with documented cross-host semantics. |
| Lua API errors in a particular directive | The API may yield, but the current ngx_lua phase does not permit that operation. | Move lock logic to a supported request phase and confirm phase restrictions for the deployed module version. |
| Dictionary writes fail or entries disappear | Insufficient shared-memory capacity or eviction under workload. | Measure key count and value size, size the zone for the workload, and define behavior for failed writes or evictions. |
9. Performance, reliability, and cost considerations
Every lock adds coordination work, and contended requests wait. Lock only the operations that need serialization, keep those operations brief, and consider atomic dictionary operations for simple counters. A shared dictionary uses Nginx shared memory; size it from measured state volume and account for the fact that local memory is not a durable, cross-host session database.
For multi-host deployments, external storage adds a network dependency and its own latency and outage behavior. Choose the backend based on required consistency and failure handling, and observe lock timeouts, update failures, and request latency. No universal timeout, dictionary size, or performance figure applies across workloads; measure with the actual state shape and traffic pattern.
10. Verify the implementation before deployment
- Confirm the runtime is OpenResty or NGINX with the required ngx_lua module and compatible
lua-resty-lockpackage. - Document the session identity key, its validation, and whether requests can land on multiple hosts.
- Write down which updates require atomicity and which can tolerate last-write-wins behavior.
- Declare shared dictionaries in the correct context and size them using expected keys and values.
- Acquire a per-session lock only around the critical update; re-read state after acquisition.
- Exercise contention, lock timeout, write failure, worker restart, Nginx reload, and external-store outage paths in a non-production environment.
- Confirm any yielding lock calls run in a supported phase and that all paths release promptly.
11. Or skip the browser setup
If your goal is capturing browser pages while you build or debug a workflow, ScreenshotNeo is a website screenshot API and MCP server. It is separate from Nginx session locking: it returns a screenshot or PDF from one GET request and does not replace your session store or concurrency design.
cURL:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
See the ScreenshotNeo API documentation for request options. Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed; an MCP server lets AI agents take screenshots; and 1,000 screenshots a month are free with no card, with paid plans starting at $5 for 3,000. Sign up for free.
FAQ
Does lua_shared_dict work across multiple servers?
No. It shares data among workers in one Nginx server instance. Use an external store or coordination layer for multi-host coordination.
Should every session request acquire a lock?
No. Lock only when the operation requires serialization and cannot be handled safely with an atomic operation or another suitable storage primitive.
Can I use a Lua module variable for session state?
It is worker-local, so it cannot serve as shared session state across workers. It is better suited to read-only or worker-local data.
Does a lock make session storage durable?
No. A lock coordinates access within its documented scope. It does not provide persistence, replication, or a session schema.


