Optimistic Vs. Pessimistic Locking In Rails

Learn how optimistic and pessimistic locking work in Rails, when to use each, and how to prevent lost updates safely.


By Jean Emmanuel Cadet • 22 min read

Optimistic vs. Pessimistic Locking in Rails
Share with friends

Two people open the same product page in your admin panel. Both change something and click save within seconds of each other. One of those saves is going to win, and the other person may never know their work was silently overwritten. Or two customers try to buy the last concert ticket at the same moment, and both receive a confirmation.

These are concurrency problems, and every Rails application with more than one user eventually runs into them. Active Record gives you two built-in strategies for handling them: optimistic locking and pessimistic locking. Understanding optimistic vs. pessimistic locking in Rails is one of the most practical steps toward building an application that stays correct when real users act at the same time.

In this guide you will learn:

  • How lost updates and race conditions happen, step by step
  • How optimistic locking uses the lock_version column and ActiveRecord::StaleObjectError
  • How pessimistic locking uses lock, with_lock, and SELECT ... FOR UPDATE
  • How transactions, isolation levels, and your database adapter change the behavior
  • When a unique index, CHECK constraint, or atomic SQL update is a better tool than either lock
  • How to test locking behavior and avoid the most common mistakes

All examples target Rails 8.1 conventions. They are written to run on SQLite where possible, and I will point out exactly where PostgreSQL behaves differently.


Understanding Database Concurrency

What are concurrent database operations?

A Rails app server such as Puma handles many requests at once, using multiple threads and often multiple processes. Background workers run alongside it. Each of them can read and write the same table at the same moment.

The database is designed to handle this safely at a low level: it will not corrupt a row if two writes arrive together. But the database does not know what your application meant to do. If two requests each read a value, calculate a new value in Ruby, and write it back, the database will happily apply both writes in order. The logic gap lives in your code, not in the database.

A simple lost update scenario

Imagine a Product with a stock of 10, and a checkout action that does this:

product = Product.find(params[:id])
product.update!(stock: product.stock - 1)

Now watch two requests run at nearly the same time:

  1. Request A reads the product. stock is 10.
  2. Request B reads the same product. stock is also 10.
  3. Request A calculates 10 - 1 = 9 and writes stock = 9.
  4. Request B, still holding the value 10 in memory, calculates 9 and writes stock = 9.

Two items were sold, but stock dropped by only one. Request A's change was overwritten without any error. That is a lost update.

Race conditions

This is a race condition: the final result depends on the timing of operations that are not coordinated. The code looks correct when you read it top to bottom, and it passes tests run one request at a time. It fails only when two requests interleave, which is why these bugs tend to appear in production under load and are hard to reproduce.

Why ordinary Active Record updates can surprise you

A normal update! writes the attributes you changed, based on whatever values your Ruby object currently holds. It does not check whether the row changed in the database after you loaded it. Wrapping the code in a transaction does not fix this by itself either, because a transaction guarantees that your own statements succeed or fail together. It does not automatically stop another transaction from reading the same old value in parallel. If you want a refresher, see this practical guide to Rails transactions.

Transactions and isolation levels

A transaction groups statements into one unit. An isolation level controls what one transaction can see of another transaction's work while both are running. The levels (read committed, repeatable read, serializable) trade off strictness against concurrency, and the defaults differ by database. PostgreSQL defaults to read committed. SQLite serializes writers, which we will cover later.

You do not need to memorize the theory. The key point is that default isolation settings do not protect a "read, calculate in Ruby, write back" pattern from lost updates. You need an explicit strategy, and locking is one of them.


What Is Optimistic Locking in Rails?

Optimistic locking assumes conflicts are rare. Instead of blocking anyone, it lets everyone read and edit freely, and it checks at save time whether someone else changed the record in the meantime. If someone did, the save is rejected.

The lock_version column

Active Record implements this with a special integer column named lock_version. When a model's table has this column, Rails automatically enables optimistic locking for that model. No extra configuration is needed.

How Rails detects a stale update

Each time you load a record, its current lock_version comes with it. When you save, Rails adds the version to the WHERE clause and increments it in the same statement. The SQL looks roughly like this:

UPDATE "products"
SET "name" = 'Mechanical Keyboard', "updated_at" = '...', "lock_version" = 1
WHERE "products"."id" = 1 AND "products"."lock_version" = 0;

If another user already saved, the row's version is no longer 0, so the WHERE clause matches zero rows. Rails sees that no row was updated and raises ActiveRecord::StaleObjectError.

Two users, same version

Suppose both users load the product at version 0. User A saves first: the row now has version 1. User B then tries to save with version 0. The statement matches nothing, and User B gets a StaleObjectError instead of silently overwriting User A's change.

Notice that no database lock was held while either person was editing. The row stayed fully available, which is why optimistic locking works well for slow, human-paced forms.

When optimistic locking fits

Optimistic locking is a good fit when conflicts are uncommon, when users hold a form open for a while, and when it is acceptable to tell a user "someone else changed this, please review." It adds almost no overhead on the happy path: one extra column and one extra condition on the update.


Implementing Optimistic Locking in Rails

Add the lock_version column

Create a migration. The column must be an integer, should not allow null, and should default to 0:

class AddLockVersionToProducts < ActiveRecord::Migration[8.1]
def change
add_column :products, :lock_version, :integer, default: 0, null: false
end
end

The default of 0 gives existing rows a valid starting version. The null: false constraint prevents a missing version from weakening the check. Run bin/rails db:migrate and you are done: Rails detects the conventional lock_version column on its own.

Watching the version change

Try this in bin/rails console:

product = Product.create!(name: "Keyboard", price_cents: 7900)
product.lock_version # => 0

product.update!(price_cents: 8500)
product.lock_version # => 1

Every successful update increments the version by one.

Two users editing the same record

Simulate two people with two separate instances of the same row:

user_a = Product.find(1)
user_b = Product.find(1)

user_a.update!(name: "Mechanical Keyboard") # succeeds, version becomes 1
user_b.update!(price_cents: 8900) # raises ActiveRecord::StaleObjectError

User B's object still believes the version is 0, so the update is rejected. This small example is also the basis of the test we will write later.

The model and controller

The model needs nothing special:

class Product < ApplicationRecord
validates :name, presence: true
validates :price_cents, numericality: { greater_than_or_equal_to: 0 }
end

For optimistic locking to work across a web form, the version loaded when the form was rendered must travel back with the submission. That means putting lock_version in a hidden field and permitting it in strong parameters:

<%# app/views/products/_form.html.erb %>
<%= form_with model: product do |form| %>
<%= form.hidden_field :lock_version %>

<%= form.label :name %>
<%= form.text_field :name %>

<%= form.label :price_cents %>
<%= form.number_field :price_cents %>

<%= form.submit %>
<% end %>
class ProductsController < ApplicationController
def update
@product = Product.find(params[:id])

if @product.update(product_params)
redirect_to @product, notice: "Product updated."
else
render :edit, status: :unprocessable_entity
end
rescue ActiveRecord::StaleObjectError
handle_conflict
end

private

def product_params
params.expect(product: [:name, :price_cents, :lock_version])
end

def handle_conflict
@latest_product = Product.find(params[:id])
@product.lock_version = @latest_product.lock_version

flash.now[:alert] = "Someone else updated this product while you were editing. " \
"Review the current values below, then save again if your changes still apply."
render :edit, status: :conflict
end
end

If you forget to include the hidden field, the form submits no version, Rails uses the version of the freshly loaded record, and the check passes even when the user's page was stale. This is one of the most common mistakes, and it fails silently.

Handling the error gracefully

Catching StaleObjectError is only half the job. The user needs a way to recover. In the controller above, we reload the latest record so we can show its current values next to the user's attempted changes, and we copy the latest lock_version into the form. If the user decides their change still makes sense and submits again, the save will succeed, but only because they saw the newer data first.

You have several recovery options:

  • Ask the user to review. Best for human-edited content. Show the conflict and let them decide.
  • Reload and ask them to redo the change. Simple and safe when edits are small.
  • Merge automatically. Only reasonable when the changed fields do not overlap, and you must write that logic yourself. Rails does not merge for you.
  • Retry automatically. Safe only for operations that can be recalculated from fresh data, such as incrementing a view counter. Keep the retry count small and reload the record inside each attempt:
def update_with_retry(product_id, attempts: 3)
tries = 0
begin
tries += 1
product = Product.find(product_id) # reload on every attempt
yield product
rescue ActiveRecord::StaleObjectError
retry if tries < attempts
raise
end
end

The exception object also carries the stale record and the attempted action through error.record and error.attempted_action, which can help with logging.

Forms and APIs

For HTML forms, the hidden field above is the whole mechanism. For JSON APIs, the same idea applies: include lock_version in the response when you return a record, require the client to send it back with the update, and respond with 409 Conflict when a stale write is detected:

rescue ActiveRecord::StaleObjectError
render json: { error: "Product was modified by someone else.",
current: ProductSerializer.new(Product.find(params[:id])) },
status: :conflict

(ProductSerializer stands in for however your API serializes records.) Some APIs expose the version through an ETag header and require an If-Match header on updates. That is a valid HTTP-level design, but Rails will not map it to lock_version for you, so you would translate it yourself.

Two details worth knowing: you can change the column name with self.locking_column = :revision, and you can disable the feature for a model with self.lock_optimistically = false.


What Is Pessimistic Locking in Rails?

Pessimistic locking assumes conflicts are likely, so it prevents them up front. Before modifying a record, your code asks the database to lock the row. Other transactions that want to lock or modify that same row must wait until the first transaction commits or rolls back.

How it works

In databases that support it, such as PostgreSQL and MySQL, Active Record asks for a row-level lock using SELECT ... FOR UPDATE:

SELECT "events".* FROM "events" WHERE "events"."id" = 1 LIMIT 1 FOR UPDATE;

The selected row stays locked until the transaction ends. A second transaction running the same query pauses at that statement until the first one finishes. When it resumes, it reads the latest committed data, so it makes its decision based on the truth, not on a stale copy.

lock and with_lock

Active Record gives you two entry points:

  • lock is a query method. Event.lock.find(id) adds the locking clause to the query.
  • with_lock is an instance method. It opens a transaction, reloads the record with a lock, and runs your block.

When pessimistic locking fits

Pessimistic locking is a good fit when many requests compete for the same row, when the operation must be serialized (such as taking the last available seat), and when handing the user a "please try again" error would be unacceptable. The cost is that other requests wait, so lock duration matters a great deal.


Implementing Pessimistic Locking in Rails

with_lock for a limited-capacity booking

Here is a seat reservation example. Event has an integer seats_remaining, and a Reservation belongs to an event:

class Event < ApplicationRecord
class SoldOut < StandardError; end

has_many :reservations, dependent: :destroy

def reserve_seat!(email:)
with_lock do
raise SoldOut, "No seats left for #{name}" if seats_remaining <= 0

update!(seats_remaining: seats_remaining - 1)
reservations.create!(email: email)
end
end
end

Here is what happens step by step. with_lock starts a transaction, reloads the event with FOR UPDATE, and runs the block. Because the row was reloaded after the lock was acquired, seats_remaining is the current value, not the one from when the object was first loaded. If a second request calls reserve_seat! at the same time, it waits at the lock, then sees the decremented count. If one seat remains, exactly one caller succeeds.

If the block raises an exception, the transaction rolls back and the exception propagates, so a failed reservation does not leave seats_remaining decremented. Also note that with_lock (through lock!) refuses to run on a record with unsaved changes, because reloading would discard them. Save or reload first.

Using lock in a query

When you need to lock one or more rows without an instance method, use lock inside an explicit transaction. This example moves money between two accounts:

class Account < ApplicationRecord
class InsufficientFunds < StandardError; end

def self.transfer!(from_id:, to_id:, amount_cents:)
raise ArgumentError, "Amount must be positive" unless amount_cents.positive?

transaction do
accounts = where(id: [from_id, to_id]).order(:id).lock.index_by(&:id)
from = accounts.fetch(from_id)
to = accounts.fetch(to_id)

raise InsufficientFunds if from.balance_cents < amount_cents

from.update!(balance_cents: from.balance_cents - amount_cents)
to.update!(balance_cents: to.balance_cents + amount_cents)
end
end
end

Ordering by id means every transfer locks accounts in the same order, which helps avoid deadlocks (more on that later). This is a teaching example: real financial systems usually record immutable ledger entries instead of only mutating balances.

Why the transaction matters

A row lock lives only as long as the transaction that acquired it. If you run Event.lock.find(id) outside a transaction, the database is in autocommit mode: the statement runs, the lock is released immediately, and you are left with a lock that protected nothing. Always acquire the lock and perform the dependent writes inside the same transaction. with_lock does this for you, which is why it is the safer default.

Keep locked sections short

Everything inside a locked transaction delays other requests that want the same row. Do database work inside the lock, and do slow work outside it:

reservation = event.reserve_seat!(email: params[:email])

# Slow or external work happens AFTER the lock is released
ReservationMailer.with(reservation: reservation).confirmation.deliver_later
PaymentGateway.capture(reservation) # external call: never inside with_lock

Network calls can hang or fail unpredictably. Holding a database lock while waiting for them can stall your whole app. If a step outside the lock fails, design a compensating action (such as releasing the seat) rather than keeping the lock open.


Optimistic Locking vs. Pessimistic Locking

Both strategies stop lost updates, but they behave very differently.

Optimistic locking lets everyone proceed and detects the conflict at write time. The loser of the race gets an error and must decide what to do. No database lock is held while users think or type.

Pessimistic locking prevents the conflict by making competing transactions wait. The second caller does not get an error in the normal case, it simply waits and then works with fresh data. The price is lock contention and the risk of timeouts or deadlocks.

Aspect

Optimistic locking

Pessimistic locking

Locking strategy

Version check on update

Row lock acquired before modifying

When conflicts are detected

At save time

Prevented up front by waiting

Database lock behavior

No lock held while editing

Row lock held until commit or rollback

Performance considerations

Low overhead, conflicts cause failed saves

Waiting under contention, risk of deadlocks

User experience

User may see a conflict message

Usually invisible, may see delays or timeouts

Typical use cases

Admin forms, profiles, collaborative edits

Inventory, balances, seat booking

Neither strategy is universally better. Optimistic locking adds application complexity in the form of conflict handling, while pessimistic locking adds operational complexity in the form of waiting and deadlock risk. The right choice depends on how often your users collide and what a collision costs your business.


Choosing the Right Locking Strategy

When optimistic locking is a good choice

  • Collaborative editing where two people might change the same document.
  • Admin interfaces where staff edit records through forms.
  • User profile updates, which rarely collide.
  • Rarely edited records where conflicts are the exception.
  • Long-lived forms, where holding a database lock for minutes while someone types would be impractical.

When pessimistic locking is a good choice

  • Inventory reservation where many buyers target the same item.
  • Financial balance updates, where both writes must apply in a consistent order.
  • Limited-capacity bookings such as seats or appointment slots.
  • Operations that must be serialized, for example processing a queue item exactly once.
  • Workflows with frequent conflicts, where constant StaleObjectError failures would frustrate users.

When neither is the best tool

Sometimes the simplest fix is not a lock at all:

  • Atomic SQL updates let the database do the arithmetic. This removes the read-modify-write gap entirely:
    # Atomic decrement without loading the record first
    Product.update_counters(product.id, stock: -1)

    # Conditional atomic update: returns how many rows changed
    updated = Product.where(id: product.id).where("stock > 0")
    .update_all("stock = stock - 1")
    raise OutOfStock if updated.zero?
    The second form guarantees stock never goes negative, with no explicit lock.
  • Unique indexes prevent duplicates such as two bookings for the same slot. See this guide to database indexes in Rails.
  • CHECK constraints enforce rules like stock >= 0 in the database itself:
    add_check_constraint :products, "stock >= 0", name: "products_stock_non_negative"
  • Foreign keys keep relationships valid.

For a deeper look at why the database is often the right enforcement layer, read about database constraints vs. Rails validations.

A practical decision framework

Ask these questions in order:

  1. Can a single atomic SQL statement, unique index, or CHECK constraint enforce the rule? If yes, use it.
  2. Is the data edited by humans through a slow form? Use optimistic locking.
  3. Do many requests compete for the same row, and must they be serialized? Use pessimistic locking.
  4. Is a conflict rare and acceptable to show to the user? Use optimistic locking.
  5. Are you on SQLite? Re-read the next section before relying on row locks.

Locking, Transactions, and Database Support

Transactions and pessimistic locks

A pessimistic lock is tied to a transaction. When the transaction commits, the changes become visible and the lock is released. When it rolls back, the changes are discarded and the lock is also released. Either way, the next waiting transaction can proceed.

Row-level locking depends on the adapter

Active Record's lock method generates SQL appropriate to the adapter, but not every database supports row-level locks. Do not assume pessimistic locking behaves identically everywhere.

PostgreSQL supports true row-level locking. FOR UPDATE locks the selected rows, and other transactions that try to lock or update them wait. PostgreSQL also supports variants. You can pass a custom clause such as lock("FOR UPDATE NOWAIT") to fail immediately instead of waiting, or lock("FOR UPDATE SKIP LOCKED") for queue-like processing. These strings are database-specific, so confirm them against your database's documentation.

SQLite has a different model. It does not provide PostgreSQL-style SELECT ... FOR UPDATE row locking, and Active Record's SQLite adapter does not emit a row-locking clause. Instead, SQLite locks at the database level: only one write transaction can be active at a time, and other writers wait or fail with a busy error depending on your timeout configuration. Recent Rails versions can start SQLite transactions in immediate mode (configurable), which acquires the write lock at the start of the transaction. This often makes with_lock behave safely in practice for a single-server app, but through a coarse database-wide lock, not a per-row one. Check your own database.yml rather than assuming defaults.

The practical consequence: code that is fast and fine on PostgreSQL may serialize all writes on SQLite, and code tested only on SQLite may hide problems that appear on PostgreSQL. If you plan to deploy on PostgreSQL, test there too. Optimistic locking, by contrast, works the same way on both, because it relies on an ordinary UPDATE ... WHERE.

Isolation levels in brief

Under PostgreSQL's default read committed level, each statement sees data committed before it began. This is why locking the row first and then reading it matters: the lock forces you to wait, and the read that follows sees the latest committed value. Rails lets you request a stricter level with transaction(isolation: :serializable), but stricter levels can abort transactions with serialization failures that your code must be prepared to retry. Treat isolation changes as a deliberate design decision, not a quick fix.


Common Locking Errors and Problems

ActiveRecord::StaleObjectError

This is the expected signal from optimistic locking. It is not a bug, it is the feature working. The problem is only when you handle it poorly, for instance by showing a generic 500 page.

Lock wait timeouts and deadlocks

With pessimistic locking, two other errors matter:

  • Lock wait timeouts: a transaction waited too long for a lock. Active Record raises ActiveRecord::LockWaitTimeout. You can also trigger a quick failure on purpose with NOWAIT on PostgreSQL. A lock_timeout can be set at the database level through database.yml session variables.
  • Deadlocks: two transactions each hold a lock the other needs. The database detects this, aborts one of them, and Active Record raises ActiveRecord::Deadlocked.

Long transactions create contention

The longer a transaction holds a lock, the longer others wait. A slow query, a large loop, or a network call inside the transaction magnifies the problem. Performance tuning helps here too, so see this guide to optimizing Active Record queries for shortening the time spent inside locks.

Inconsistent lock ordering

Deadlocks often come from locking rows in different orders. If one request locks account 1 then account 2, and another locks account 2 then account 1, each can end up waiting on the other. Always lock multiple rows in a consistent order, as we did with .order(:id) in the transfer example.

Reduce the problem in practice

  • Keep locked blocks short and database-only.
  • Lock as few rows as you need.
  • Lock in a consistent order.
  • Do external API calls and email delivery after the transaction commits.
  • Make sure the columns you query by are indexed, so the database does not scan and lock more than necessary.

Retry with care

It is tempting to wrap everything in a retry loop. Resist that. Retrying is appropriate only for transient failures, such as a deadlock or a serialization failure, and only if the operation is safe to repeat. If your block charged a card or sent an email before failing, a blind retry could do it twice. Limit the number of attempts, reload fresh data on each attempt, and make operations idempotent where you can.


Testing Locking Behavior in Rails

Testing optimistic locking

You can test optimistic locking in a plain Rails test using two instances of the same record:

require "test_helper"

class ProductLockingTest < ActiveSupport::TestCase
test "second stale update raises StaleObjectError" do
product = products(:keyboard)
first_copy = Product.find(product.id)
second_copy = Product.find(product.id)

first_copy.update!(name: "Updated name")

assert_raises(ActiveRecord::StaleObjectError) do
second_copy.update!(price_cents: 9900)
end

assert_equal "Updated name", product.reload.name
end
end

To test the full user flow, send a stale version to the controller:

class ProductsControllerTest < ActionDispatch::IntegrationTest
test "stale form submission shows a conflict" do
product = products(:keyboard)
stale_version = product.lock_version
product.update!(name: "Changed elsewhere")

patch product_path(product),
params: { product: { name: "My edit", lock_version: stale_version } }

assert_response :conflict
assert_equal "Changed elsewhere", product.reload.name
end
end

Testing pessimistic locking logic

You can verify the business rules of a locked method in an ordinary test: it succeeds while seats remain, raises when sold out, and rolls back on failure:

test "sold out event raises and leaves no reservation" do
event = Event.create!(name: "Workshop", seats_remaining: 0)

assert_raises(Event::SoldOut) { event.reserve_seat!(email: "[email protected]") }

assert_equal 0, event.reload.seats_remaining
assert_equal 0, event.reservations.count
end

This confirms your logic and rollback behavior, but it does not prove the lock works under real concurrency.

The limits of single-threaded tests

Rails tests normally run inside a transaction on one database connection. Real concurrency needs separate connections, because a lock only blocks a different transaction. To test it, turn off transactional tests for that class, run threads that each check out their own connection, and clean up your own data:

class EventConcurrencyTest < ActiveSupport::TestCase
self.use_transactional_tests = false

setup { @event = Event.create!(name: "Workshop", seats_remaining: 1) }

teardown do
Reservation.where(event_id: @event.id).delete_all
@event.destroy
end

test "only one of two concurrent bookings succeeds" do
outcomes = Queue.new

threads = 2.times.map do |i|
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
Event.find(@event.id).reserve_seat!(email: "user#{i}@example.com")
outcomes << :booked
rescue Event::SoldOut
outcomes << :sold_out
end
end
end
threads.each(&:join)

results = Array.new(outcomes.size) { outcomes.pop }
assert_equal [:booked, :sold_out], results.sort
end
end

Run this against the database you deploy on. SQLite's database-level write lock and PostgreSQL's row locks behave differently, and timing-based tests can occasionally be flaky, so treat them as a safety net and keep them few and focused.


Common Mistakes and Best Practices

  • Forgetting the lock_version column. Without it, Rails does no optimistic checking and you get no warning.
  • Forgetting the hidden field or the strong parameter. The form must send the version back, or stale pages pass the check.
  • Assuming optimistic locking prevents every race. It protects updates to a single row. It does not stop duplicate inserts or inconsistencies across multiple rows.
  • Rescuing StaleObjectError without a recovery path. Show the user what changed and how to proceed.
  • Holding pessimistic locks too long. Keep blocks short.
  • Calling external APIs inside locked transactions. Do that work after the commit.
  • Assuming SQLite and PostgreSQL lock the same way. They do not.
  • Ignoring deadlocks and lock wait timeouts. Log them, lock in a consistent order, and handle them.
  • Retrying unsafe operations. Only retry what is safe to repeat, with a limit.
  • Using a lock where a simpler tool works. Prefer a unique index, CHECK constraint, foreign key, or atomic UPDATE when it fully expresses the rule.

A reliable approach is to push invariants into the database when you can, use optimistic locking for human-edited records, reserve pessimistic locking for contended rows that must be serialized, and keep every locked section as small as possible.


Conclusion

Optimistic locking detects conflicting updates: it lets everyone work, checks lock_version at save time, and raises ActiveRecord::StaleObjectError when a record is stale. Pessimistic locking coordinates access: it uses database row locks, where supported, through lock and with_lock so competing transactions wait their turn.

Neither is universally better. Choose based on how often your users collide, how long operations take, and what a conflict costs your business. Remember that a unique index, CHECK constraint, or atomic SQL update is sometimes the simplest and strongest answer, and that your database adapter changes how locking really behaves.

Most importantly, do not assume concurrent code is safe because it passes sequential tests. Write a test that uses two instances or two connections, run it against your production database engine, and watch what actually happens when two users act at once.

Jean Emmanuel Cadet
Written by Jean Emmanuel Cadet
Jean Emmanuel is a Full-Stack Software Engineer specializing in Ruby on Rails and the modern Rails ecosystem. He builds scalable, maintainable web applications using Rails, Hotwire (Turbo & Stimulus), PostgreSQL, and SQLite, with a focus on fast, dynamic user experiences. Through CodeCurious, he shares practical lessons, development insights, and real-world solutions for modern developers.

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.