← All posts
Backend9 min readProject: Product configurator engine

Two users, one configurator: building edit locks with Redis and WebSockets

Two people opened the same configurator, both edited it, and the last save silently erased the other's work. Here is how I designed a lock system with Redis and Socket.IO: lock levels, who blocks whom, how the HTTP API enforces it, and how the UI tells you exactly who is editing.

#Redis#WebSockets#Socket.IO#NestJS#Concurrency#Distributed Locks

The problem

Two users could edit the same configurator at the same time, and the last save silently overwrote the other user's changes.

The result

Editing is now protected by Redis locks at three levels; a blocked user gets a clear 423 "Currently edited by…" instead of losing work.

Stack

NestJSSocket.IORedisioredisPostgreSQL

The problem

The configurator engine is a collaborative tool. Several product specialists work in it every day, and they often work on the same products.

A configurator is a tree of items. Each item has sub-pages: filters (when the item is shown), dependencies (what it changes when selected), attribute values, and a separate page of computed variables for the whole configurator. Editing one of these pages can take a while: reading the logic, changing several fields, saving.

Then this happened:

  1. User A opens a configurator and starts restructuring the item tree.
  2. User B opens the same configurator ten minutes later and edits a few filters.
  3. User A saves. User B saves.
  4. One of them has silently lost work. Nobody got an error. Nobody knew.

This is the classic lost update problem. Databases protect a single row inside a transaction, but they can't protect a human editing session that lasts twenty minutes across dozens of requests. I needed editing locks: while someone is editing something, others can look, but not change it.

The details

What exactly needs to be locked?

The first design question is the granularity. Lock too much, and people block each other for nothing. Lock too little, and conflicts slip through.

I mapped the real editing screens to three levels:

LockKey shapeMeaning
Whole configuratorconfigurator:{id}"I'm restructuring this configurator"
One item's sub-pageitem:{id}:filters, item:{id}:dependencies, item:{id}:attribute-values"I'm editing this item's filters"
Computed variables pageconfigurator:{id}:variables"I'm editing this configurator's variables"

Each lock is a small JSON value in Redis:

{
  "ownerUserId": 42,
  "ownerUserName": "Sara",
  "socketId": "Xk3…",
  "expiresAt": "2026-10-18T10:30:00.000Z"
}

Storing the owner's name with the lock means the UI can say "Currently edited by Sara" without any extra lookup.

The rules: who blocks whom

Levels interact. Getting these rules right was most of the work:

  • Locking a configurator fails if:
    • another user holds that configurator, or
    • another user holds its variables page (the two block each other), or
    • another user is editing the filters or dependencies of any item inside it.
  • Locking an item's sub-page fails if:
    • another user holds the parent configurator, or
    • another user holds that same sub-page.
  • Locking the variables page fails if another user holds the configurator (and the reverse).

So someone restructuring the whole tree blocks item-level editing, and someone editing one item's filters prevents a restructure from starting underneath them. But two people can edit different items' filters in the same configurator at the same time, because those don't conflict.

Making "item is inside a locked configurator" cheap

When a user locks a configurator, the system also writes an item:{id} key for every item in it, plus an index set configurator-items:{id} listing those item ids.

Why duplicate? Because later checks become one Redis GET. When someone tries to save an item, the API checks item:{id}. If it exists, the item belongs to a configurator someone else is editing. No database query is needed to find the parent configurator. The index set also makes release easy: read the set, delete all those keys.

One configurator per user

A person can only edit one configurator at a time. If they lock configurator 12 while still holding configurator 7, the system releases 7 first and tells everyone it is free. This prevents a common real-world leak: someone opens a second tab or jumps to another product and forgets the first one was still locked.

Every lock expires

Every key is written with a TTL of 30 minutes (SET key value EX 1800). Even if everything else fails (a crash, a bug, a lost network event), a forgotten lock frees itself. The next post covers how locks are released properly on disconnect. The TTL is the last safety net under all of that.

The challenges

Locks must be enforced by the API, not just the UI

The first version of any lock system is usually "the UI greys out the Save button". That protects nobody: a second tab, an old page, a script or a direct API call goes straight through. The backend has to refuse writes from anyone who doesn't hold the lock.

The API speaks in many different ids

Some routes receive an item id (PATCH /items/:id). Others receive only a filter id, a dependency id, an attribute value id or a variable id. To check the right lock, each id must be resolved to the item or configurator it belongs to.

Real time, both ways

When Sara releases a lock, Omar, who is waiting on the same page, should see the page become editable immediately, not after a refresh. Locks are naturally tied to a live connection, so this needed a real-time channel.

Clear errors for humans

"Error 500" or "Forbidden" would make people think the app is broken. A blocked user must understand who is editing and that it is temporary.

The solution

WebSockets for acquiring and releasing

Locks are taken and released over Socket.IO, in a dedicated /locking namespace. The client opens a page and emits an event; the server answers on the same socket and broadcasts to everyone:

Client → Server                     Server → Client
───────────────                     ───────────────
lock-configurator { id }       →    lock-acquired  (to the requester)
lock-item-subresource { … }    →    lock-failed    { message, lockedBy }
lock-variables { id }          →    lock-updated   (broadcast: who holds what)
unlock-…                       →    lock-released  (broadcast: it's free again)

Tying locks to a socket has a big advantage: the server knows when the user leaves. When the socket disconnects (tab closed, laptop asleep, network lost), the server can release that user's locks. That cleanup is the topic of the next post.

The acquire flow

Rewritten and simplified, acquiring a configurator lock looks like this:

async acquireConfigurator(configuratorId: number, user: User, socketId: string) {
  // One configurator at a time: release any other one this socket holds
  const released = await this.releasePreviousConfigurator(socketId, configuratorId);

  // Refuse if anyone else holds a conflicting lock
  await this.assertFree(`configurator:${configuratorId}`, user.id);
  await this.assertFree(`configurator:${configuratorId}:variables`, user.id);

  const itemIds = await this.db.findActiveItemIds(configuratorId);
  for (const itemId of itemIds) {
    await this.assertFree(`item:${itemId}:filters`, user.id);
    await this.assertFree(`item:${itemId}:dependencies`, user.id);
  }

  // Take the lock, mark every item, remember what this socket owns
  const lock = { ownerUserId: user.id, ownerUserName: user.name, socketId, expiresAt };
  await this.redis.setLock(`configurator:${configuratorId}`, lock);
  for (const itemId of itemIds) {
    await this.redis.setLock(`item:${itemId}`, { ...lock, configuratorId });
  }
  await this.redis.setConfiguratorItems(configuratorId, itemIds);
  await this.redis.trackSocketLock(socketId, `configurator:${configuratorId}`);

  return { acquired: true, configuratorId, released };
}

And assertFree is the single place that decides what "blocked" means:

private async assertFree(key: string, userId: number) {
  const lock = await this.redis.getLock(key);
  if (lock && lock.ownerUserId !== userId) {
    throw new HttpException(
      { message: `Currently edited by ${lock.ownerUserName}`, lockedBy: lock.ownerUserName },
      HttpStatus.LOCKED, // 423
    );
  }
}

Note the check ownerUserId !== userId: your own lock never blocks you, even from another tab.

HTTP enforcement with a decorator and a guard

Every write route declares which lock protects it with a decorator. A global guard reads the decorator, finds the resource id, and checks Redis before the handler runs:

@Patch(':id')
@RequireLock({ type: 'item', paramKey: 'id' })
updateItem(@Param('id') id: number, @Body() dto: UpdateItemDto) { … }

@Patch('filters/:id')
@RequireLock({ type: 'filter-id', paramKey: 'id' })
updateFilter(@Param('id') id: number, @Body() dto: UpdateFilterDto) { … }

The guard delegates to a checker that knows how each type maps to lock keys:

  • item → check item:{id} (covers "parent configurator is locked").
  • item-full (delete an item) → check the item and its filters and dependencies locks.
  • filter-id → find the filter's item, then check that item's filters lock.
  • dependency-id, attribute-value-id, variable-id → same idea, resolved to the right parent.
  • configurator → check the configurator, its variables page, and every item's sub-locks.

Routes without @RequireLock() pass through untouched, so reads stay fast and simple. A blocked write gets 423 Locked, the HTTP status made exactly for this, with the editor's name in the body.

What the user sees

Because every refusal carries lockedBy, the front end can show:

🔒 Currently edited by Sara. You can view this page. It will become editable when she's done.

And thanks to the lock-released broadcast, the page unlocks itself the moment Sara leaves.

Lessons learned

  • Model locks on real screens. The right granularity came from asking "what do people actually edit at the same time?", not from the database schema.
  • Write the conflict rules down as a table. Who blocks whom is the heart of the design. Bugs hide in the combinations, like "item sub-page vs. parent configurator".
  • Enforce on the server. A disabled button is UX. A 423 from the API is protection.
  • Denormalize for cheap checks. Writing item:{id} keys and an index set at lock time turns every later check into one Redis read.
  • Your own lock never blocks you. Compare owners, not just existence, or users will lock themselves out across tabs.
  • Everything expires. A TTL on every lock means no bug can freeze a configurator forever.
  • Errors should name a person. "Currently edited by Sara" turns a frustrating error into a normal, understandable situation.

FAQ

What is the lost update problem?

Two users read the same data, both change it, and both save. The second save overwrites the first, and the first user's changes are lost without any error. Edit locks, version numbers (optimistic locking) or merging are the usual solutions.

Why use Redis for application locks?

Redis is fast, shared by every instance of the service, and supports expiry (TTL) natively. That makes it a good fit for short-lived locks that many servers must agree on.

What HTTP status code should a locked resource return?

423 Locked (from WebDAV) means the resource is locked. It is more precise than 409 Conflict or 403 Forbidden, and it tells the client that retrying later can succeed.

Why combine WebSockets with HTTP for locking?

WebSockets give real-time updates (who holds what, when it's released) and let the server detect when a user disconnects. HTTP guards make sure every write is checked, no matter how the request is sent.

Facing a similar problem?

I help teams fix performance, backend and reliability issues in production.

Let's talk
Welcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have fun

Componentity

Thanks for visiting my portfolio. Every project here reflects my passion for building and learning. If you’re interested in working together, have a question, or just want to connect—don’t hesitate to reach out. I'm always open to meaningful conversations and new ideas.

© 2026 Zakariya Zouazou. All Rights Reserved.

Contact Us