A “Mark All as Read” button looks simple. But the implementation quickly becomes wrong when the browser loops through notifications and sends one request for each item.
With that approach, the client effectively decides what “all” means. If only the first 20 records out of 300 are loaded, the loop marks only those 20 as read.
A bulk action should be modeled on the server as one business operation: mark every unread notification for the current user as read.
A Browser Request Loop Puts the Operation Boundary in the Wrong Place
for (const notification of notifications.value) { await api.markAsRead( notification.id, );}The problem is not only the number of requests.
The result now depends on:
- pagination;
- how many records are already loaded;
- request execution order;
- network failures in the middle of the loop;
- repeated button clicks;
- whether new notifications appear while the loop is running.
“All” Has a Concrete Meaning on the Server
The server has access to the complete data set and can define the operation independently of the current screen.
For example: update every notification belonging to the current user where read_at IS NULL.
$markedCount = Notification::query() ->where( 'user_id', $user->id, ) ->whereNull( 'read_at', ) ->update([ 'read_at' => now(), ]);The Critical Part Is Scoping the Operation to the Current User
A bulk UPDATE becomes dangerous when its conditions are vague.
“Mark all as read” must not mean “update every unread row in the table.” It must mean “update every unread notification belonging to this user.”
If the system supports multiple contexts, organizations, or notification types, the required constraints should be part of the same server query.
Repeated Execution Should Be Safe
The user may click twice. The browser may retry the request. The network may fail after the server completes the update but before the response reaches the client.
If the server updates only rows where read_at IS NULL, a repeated call simply finds nothing new to change.
This naturally makes the operation idempotent from the perspective of final state.
One Timestamp Is Better Than Hundreds of Slightly Different Values
$readAt = now(); $markedCount = Notification::query() ->where( 'user_id', $user->id, ) ->whereNull( 'read_at', ) ->update([ 'read_at' => $readAt, ]);If this is logically one user operation, I prefer to capture one timestamp and use it for every updated row.
The data then reflects the real fact more clearly: the user performed one bulk action at one specific moment.
The Server Response Should Help the Client Reach the Correct State
return response()->json([ 'success' => true, 'markedCount' => $markedCount, 'unreadCount' => 0,]);After one successful response, the client does not need to wait for dozens of separate requests or track which records have already completed.
It can set the canonical unread count and update the notifications that are already present in local memory.
The Client Updates Local Representation but Does Not Define the Operation Scope
async function markAllAsRead() { const result = await api.markAllAsRead(); unreadCount.value = result.unreadCount; notifications.value = notifications.value.map( (notification) => ({ ...notification, readAt: notification.readAt ?? new Date().toISOString(), }), );It is important to separate two responsibilities.
The server decides which records belong to “all.” The browser only reflects the result for the subset already present in the interface.
The next page of pagination will already come from the server with the correct read_at, so the client never needed to preload it.
What If a New Notification Appears During the Bulk Action
This requires an explicit product rule.
The button may mean one of two things:
- mark everything that existed when the user clicked;
- mark everything matching the condition when the server executes the
UPDATE.
For most ordinary interfaces, the second interpretation is natural enough. If the action boundary must be strict, capture it explicitly, for example with created_at <= $actionStartedAt.
A Transaction Is Not Always Required, but the Operation Boundary Must Be Clear
If the entire action is one SQL UPDATE, the database already executes that statement as one operation.
If the same action must also change a separate counter, write an audit record, or create related state, then decide whether those changes belong in one transaction.
The goal is not to add a transaction mechanically, but to identify the real consistency boundary.
A “Mark All” Route Should Not Accept an Arbitrary user_id from the Client
If the operation belongs to the currently authenticated user, the user identifier should come from the authentication context.
A request such as POST /notifications/read-all does not need a user_id in the body merely so the server can trust that value later.
This reduces the error surface and makes the authorization boundary clearer.
Do Not Turn One Bulk Action into Hundreds of Identical Events Without a Reason
If changing read_at does not require a separate business reaction for every notification, there is no need to load every model and call save() only to trigger model events.
A bulk UPDATE may be both more correct and much cheaper.
If each change truly must trigger important per-item behavior, that is a different requirement and should be handled deliberately.
What to Test
1. Only the current user's unread notifications are updated.2. Already-read notifications keep their existing read_at.3. Notifications belonging to another user are untouched.4. The operation works even when the UI has loaded only one page.5. Repeating the request is safe.6. The response returns the expected unread count.7. A new notification created after the operation remains unread.8. Required tenant or organization scope is respected.9. Authorization does not rely on user_id supplied by the browser.10. The client updates loaded rows after one successful server response.A Bulk Action Should Be Bulk at the Server Boundary
“Mark all as read” is a good example of how the client/server boundary affects system correctness.
The browser should not enumerate loaded items and decide what belongs to “all.” It should ask the server to perform one clearly defined operation.
The server defines the complete set, performs the bulk update, and returns canonical state. The interface only reflects that result.
If a button means “all,” its result should not depend on how many rows the browser happened to load.



