diff --git a/packages/toolkit/src/query/apiTypes.ts b/packages/toolkit/src/query/apiTypes.ts
--- a/packages/toolkit/src/query/apiTypes.ts
+++ b/packages/toolkit/src/query/apiTypes.ts
@@ -45,6 +45,7 @@ export type Module<Name extends ModuleName> = {
       | 'refetchOnMountOrArgChange'
       | 'refetchOnFocus'
       | 'refetchOnReconnect'
+      | 'invalidationBehavior'
       | 'tagTypes'
     >,
     context: ApiContext<Definitions>
diff --git a/packages/toolkit/src/query/core/apiState.ts b/packages/toolkit/src/query/core/apiState.ts
--- a/packages/toolkit/src/query/core/apiState.ts
+++ b/packages/toolkit/src/query/core/apiState.ts
@@ -254,6 +254,7 @@ export type ConfigState<ReducerPath> = RefetchConfigOptions & {
 
 export type ModifiableConfigState = {
   keepUnusedDataFor: number
+  invalidationBehavior: 'delayed' | 'immediately'
 } & RefetchConfigOptions
 
 export type MutationState<D extends EndpointDefinitions> = {
diff --git a/packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts b/packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts
--- a/packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts
+++ b/packages/toolkit/src/query/core/buildMiddleware/invalidationByTags.ts
@@ -1,8 +1,16 @@
-import { isAnyOf, isFulfilled, isRejectedWithValue } from '../rtkImports'
+import {
+  isAnyOf,
+  isFulfilled,
+  isRejected,
+  isRejectedWithValue,
+} from '../rtkImports'
 
-import type { FullTagDescription } from '../../endpointDefinitions'
+import type {
+  EndpointDefinitions,
+  FullTagDescription,
+} from '../../endpointDefinitions'
 import { calculateProvidedBy } from '../../endpointDefinitions'
-import type { QueryCacheKey } from '../apiState'
+import type { CombinedState, QueryCacheKey } from '../apiState'
 import { QueryStatus } from '../apiState'
 import { calculateProvidedByThunk } from '../buildThunks'
 import type {
@@ -18,6 +26,7 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
   context,
   context: { endpointDefinitions },
   mutationThunk,
+  queryThunk,
   api,
   assertTagType,
   refetchQuery,
@@ -29,6 +38,13 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
     isRejectedWithValue(mutationThunk)
   )
 
+  const isQueryEnd = isAnyOf(
+    isFulfilled(mutationThunk, queryThunk),
+    isRejected(mutationThunk, queryThunk)
+  )
+
+  let pendingTagInvalidations: FullTagDescription<string>[] = []
+
   const handler: ApiMiddlewareInternalHandler = (action, mwApi) => {
     if (isThunkActionWithTags(action)) {
       invalidateTags(
@@ -38,12 +54,11 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
           endpointDefinitions,
           assertTagType
         ),
-        mwApi,
-        internalState
+        mwApi
       )
-    }
-
-    if (api.util.invalidateTags.match(action)) {
+    } else if (isQueryEnd(action)) {
+      invalidateTags([], mwApi)
+    } else if (api.util.invalidateTags.match(action)) {
       invalidateTags(
         calculateProvidedBy(
           action.payload,
@@ -53,21 +68,38 @@ export const buildInvalidationByTagsHandler: InternalHandlerBuilder = ({
           undefined,
           assertTagType
         ),
-        mwApi,
-        internalState
+        mwApi
       )
     }
   }
 
+  function hasPendingRequests(state: CombinedState<EndpointDefinitions, string, string>) {
+    for (const key in state.queries) {
+      if (state.queries[key]?.status === QueryStatus.pending) return true;
+    }
+    for (const key in state.mutations) {
+      if (state.mutations[key]?.status === QueryStatus.pending) return true;
+    }
+    return false;
+  }
+
   function invalidateTags(
-    tags: readonly FullTagDescription<string>[],
-    mwApi: SubMiddlewareApi,
-    internalState: InternalMiddlewareState
+    newTags: readonly FullTagDescription<string>[],
+    mwApi: SubMiddlewareApi
   ) {
     const rootState = mwApi.getState()
-
     const state = rootState[reducerPath]
 
+    pendingTagInvalidations.push(...newTags)
+
+    if (state.config.invalidationBehavior === 'delayed' && hasPendingRequests(state)) {
+      return
+    }
+
+    const tags = pendingTagInvalidations
+    pendingTagInvalidations = []
+    if (tags.length === 0) return
+
     const toInvalidate = api.util.selectInvalidatedBy(rootState, tags)
 
     context.batch(() => {
diff --git a/packages/toolkit/src/query/core/module.ts b/packages/toolkit/src/query/core/module.ts
--- a/packages/toolkit/src/query/core/module.ts
+++ b/packages/toolkit/src/query/core/module.ts
@@ -452,6 +452,7 @@ export const coreModule = (): Module<CoreModule> => ({
       refetchOnMountOrArgChange,
       refetchOnFocus,
       refetchOnReconnect,
+      invalidationBehavior,
     },
     context
   ) {
@@ -514,6 +515,7 @@ export const coreModule = (): Module<CoreModule> => ({
         refetchOnMountOrArgChange,
         keepUnusedDataFor,
         reducerPath,
+        invalidationBehavior,
       },
     })
 
diff --git a/packages/toolkit/src/query/createApi.ts b/packages/toolkit/src/query/createApi.ts
--- a/packages/toolkit/src/query/createApi.ts
+++ b/packages/toolkit/src/query/createApi.ts
@@ -151,6 +151,16 @@ export interface CreateApiOptions<
    * Note: requires [`setupListeners`](./setupListeners) to have been called.
    */
   refetchOnReconnect?: boolean
+  /**
+   * Defaults to `'immediately'`. This setting allows you to control when tags are invalidated after a mutation.
+   *
+   * - `'immediately'`: Queries are invalidated instantly after the mutation finished, even if they are running.
+   *   If the query provides tags that were invalidated while it ran, it won't be re-fetched.
+   * - `'delayed'`: Invalidation only happens after all queries and mutations are settled.
+   *   This ensures that queries are always invalidated correctly and automatically "batches" invalidations of concurrent mutations.
+   *   Note that if you constantly have some queries (or mutations) running, this can delay tag invalidations indefinitely.
+   */
+  invalidationBehavior?: 'delayed' | 'immediately'
   /**
    * A function that is passed every dispatched action. If this returns something other than `undefined`,
    * that return value will be used to rehydrate fulfilled & errored queries.
@@ -255,6 +265,7 @@ export function buildCreateApi<Modules extends [Module<any>, ...Module<any>[]]>(
       refetchOnMountOrArgChange: false,
       refetchOnFocus: false,
       refetchOnReconnect: false,
+      invalidationBehavior: 'delayed',
       ...options,
       extractRehydrationInfo,
       serializeQueryArgs(queryArgsApi) {

diff --git a/packages/toolkit/src/query/tests/buildSlice.test.ts b/packages/toolkit/src/query/tests/buildSlice.test.ts
--- a/packages/toolkit/src/query/tests/buildSlice.test.ts
+++ b/packages/toolkit/src/query/tests/buildSlice.test.ts
@@ -51,6 +51,7 @@ describe('buildSlice', () => {
       api: {
         config: {
           focused: true,
+          invalidationBehavior: 'delayed',
           keepUnusedDataFor: 60,
           middlewareRegistered: true,
           online: true,
diff --git a/packages/toolkit/src/query/tests/raceConditions.test.ts b/packages/toolkit/src/query/tests/raceConditions.test.ts
new file mode 100644
--- /dev/null
+++ b/packages/toolkit/src/query/tests/raceConditions.test.ts
@@ -0,0 +1,109 @@
+import { createApi, QueryStatus } from '@reduxjs/toolkit/query'
+import { getLog } from 'console-testing-library'
+import { actionsReducer, setupApiStore, waitMs } from './helpers'
+
+// We need to be able to control when which query resolves to simulate race
+// conditions properly, that's the purpose of this factory.
+const createPromiseFactory = () => {
+  const resolveQueue: (() => void)[] = []
+  const createPromise = () =>
+    new Promise<void>((resolve) => {
+      resolveQueue.push(resolve)
+    })
+  const resolveOldest = () => {
+    resolveQueue.shift()?.()
+  }
+  return { createPromise, resolveOldest }
+}
+
+const getEatenBananaPromises = createPromiseFactory()
+const eatBananaPromises = createPromiseFactory()
+
+let eatenBananas = 0
+const api = createApi({
+  invalidationBehavior: 'delayed',
+  baseQuery: () => undefined as any,
+  tagTypes: ['Banana'],
+  endpoints: (build) => ({
+    // Eat a banana.
+    eatBanana: build.mutation<unknown, void>({
+      queryFn: async () => {
+        await eatBananaPromises.createPromise()
+        eatenBananas += 1
+        return { data: null, meta: {} }
+      },
+      invalidatesTags: ['Banana'],
+    }),
+
+    // Get the number of eaten bananas.
+    getEatenBananas: build.query<number, void>({
+      queryFn: async (arg, arg1, arg2, arg3) => {
+        const result = eatenBananas
+        await getEatenBananaPromises.createPromise()
+        return { data: result }
+      },
+      providesTags: ['Banana'],
+    }),
+  }),
+})
+const { getEatenBananas, eatBanana } = api.endpoints
+
+const storeRef = setupApiStore(api, {
+  ...actionsReducer,
+})
+
+it('invalidates a query after a corresponding mutation', async () => {
+  eatenBananas = 0
+
+  const query = storeRef.store.dispatch(getEatenBananas.initiate())
+  const getQueryState = () =>
+    storeRef.store.getState().api.queries[query.queryCacheKey]
+  getEatenBananaPromises.resolveOldest()
+  await waitMs(2)
+
+  expect(getQueryState()?.data).toBe(0)
+  expect(getQueryState()?.status).toBe(QueryStatus.fulfilled)
+
+  const mutation = storeRef.store.dispatch(eatBanana.initiate())
+  const getMutationState = () =>
+    storeRef.store.getState().api.mutations[mutation.requestId]
+  eatBananaPromises.resolveOldest()
+  await waitMs(2)
+
+  expect(getMutationState()?.status).toBe(QueryStatus.fulfilled)
+  expect(getQueryState()?.data).toBe(0)
+  expect(getQueryState()?.status).toBe(QueryStatus.pending)
+
+  getEatenBananaPromises.resolveOldest()
+  await waitMs(2)
+
+  expect(getQueryState()?.data).toBe(1)
+  expect(getQueryState()?.status).toBe(QueryStatus.fulfilled)
+})
+
+it('invalidates a query whose corresponding mutation finished while the query was in flight', async () => {
+  eatenBananas = 0
+
+  const query = storeRef.store.dispatch(getEatenBananas.initiate())
+  const getQueryState = () =>
+    storeRef.store.getState().api.queries[query.queryCacheKey]
+
+  const mutation = storeRef.store.dispatch(eatBanana.initiate())
+  const getMutationState = () =>
+    storeRef.store.getState().api.mutations[mutation.requestId]
+  eatBananaPromises.resolveOldest()
+  await waitMs(2)
+  expect(getMutationState()?.status).toBe(QueryStatus.fulfilled)
+
+  getEatenBananaPromises.resolveOldest()
+  await waitMs(2)
+  expect(getQueryState()?.data).toBe(0)
+  expect(getQueryState()?.status).toBe(QueryStatus.pending)
+
+  // should already be refetching
+  getEatenBananaPromises.resolveOldest()
+  await waitMs(2)
+
+  expect(getQueryState()?.status).toBe(QueryStatus.fulfilled)
+  expect(getQueryState()?.data).toBe(1)
+})
diff --git a/packages/toolkit/src/tests/combineSlices.test.ts b/packages/toolkit/src/tests/combineSlices.test.ts
--- a/packages/toolkit/src/tests/combineSlices.test.ts
+++ b/packages/toolkit/src/tests/combineSlices.test.ts
@@ -33,6 +33,7 @@ const api = {
       subscriptions: {},
       config: {
         reducerPath: 'api',
+        invalidationBehavior: 'delayed',
         online: false,
         focused: false,
         keepUnusedDataFor: 60,

Document how to batch RTK Query mutations properly
I feel that the documentation needs to cover the following scenario.

I have an array of objects that I need to send to the API. The API only allows mutating one object per request. What is a good way to do this while:

- avoiding a race condition where the final cache invalidation is ignored because the second to last cache provide comes later.
- even better, firing just one cache invalidation after all mutations have been sent.

I understand mutations can probably use a `queryFn` to do this, but the docs only cover how to do multiple fetches.

From the "endpoint specification" side, there is literally no difference between queries and mutations, apart that queries provideTags and mutations invalidateTags. Doing it with a `queryFn` is a fine way to do it - with exactly the code as you would do with a query.
Thanks, that helped. I was able to get it working using this:

```
    updateAccessLevelRequests: builder.mutation<number, AccessLevelRequest[]>({
      async queryFn(accessLevelRequests, _queryApi, _extraOptions, baseQuery) {
        for (const accessLevelRequest of accessLevelRequests) {
          await baseQuery({
            url: `access-level-requests/${accessLevelRequest.id}/`,
            method: 'PUT',
            body: accessLevelRequest,
          });
        }
        return { data: accessLevelRequests.length };
      },
      invalidatesTags: ['AccessRequests'],
    }),
```

I still think it would be good to show this in the docs, though.
PRs to the docs are always welcome :)
I know. For now I wanted to at least record it as an issue.
@phryneas just wanted to add that this is not a good solution when using an auto generated RTK api file.
manually adding queries in this use case is beating the purpose. 
RTK Query needs a proper way to batch multiple mutations and merge their respective invalidation tags when they're both done. otherwise there are use cases where race conditions occur. 
Agree with @giladv 

I have to call two mutations on an update of a record. Two different endpoints. Both invalidate the same tag. Frequently results in race conditions and the user only sees part of the update in the UI.

And the API file is generated from OpenAPI
Short answer is that isn't a use case we considered in the initial design, there haven't been many requests for it (other than this thread), and designing that functionality isn't a thing that's on our radar.

Not saying it's impossible or will never be done, just that it's not a thing we've spent time thinking about.
Removing the actual implementation details for a moment, is it not a concern that two mutations called in quick succession - irrespective of what they are doing - would cause a race condition if they try an invalidate the same tag?

To me that sounds like a bug that could raise it's head in any number of scenarios outside of the one in this thread.
@slashwhatever : I don't see it as a "bug" in the sense of "the library code is behaving in an unexpected or unintended way", no.  I would potentially see it as a _limitation_ in the design, but the library code is working as intended as far as I know.
Are more use-cases helpful for you? If so:

Currently, I have a list of document ids of unknown length and I need to fetch metadata and preview content for all those documents. But every document needs its own request. And I need this metadata for further calculations, not in separate components where this wouldn't be a problem.

E.g.:

``` js
const documentIds = ['1', '2', '3', '4']
```

Would result in those requests:

```
GET /api/document/1/metadata
GET /api/document/1/preview
GET /api/document/2/metadata
GET /api/document/2/preview
etc.
```
Here's an example of how I resolved my use case:

```
    updateAllTheThings: build.mutation<
      UpdateThingsResponse | FetchBaseQueryError,
      UpdateThingsRequest & SetOtherThingsRequest
    >({
      async queryFn(queryArg, _queryApi, _extraOptions, fetchWithBaseQuery) {
        const { thingId, foo, bar } = queryArg.body;
        const { baz } = queryArg.body;

        const updateOneThing = await fetchWithBaseQuery({
          url: `thingEndpoint/thing/${queryArg.thingId}/set-things`,
          method: "PATCH",
          body: { baz },
        });

        if (updateOneThing.error) return { error: updateOneThing.error };

        const updateOtherThing = await fetchWithBaseQuery({
          url: `otherThingEndpoint/otherThing/${queryArg.thingId}`,
          method: "PATCH",
          body: { foo, bar },
        });

        if (updateOtherThing.error) return { error: updateOtherThing.error };

        return { data: updateOtherThing.data as UpdateThingsResponse };
      },
      invalidatesTags: ["Things"],
    }),

```

In this specific case, the order of requests was important. If the first failed, it should not send the second and return the error. There wasn't any way around the problem that we generate our code from OpenAPI but we've dealt with that internally. You could, I guess, set up another extended api for your manually created ones and just inject it with `api.injectEndpoints`

@alinnert Not sure if this helps you think about how to solve for your use case, but I figured it was worth sharing in case someone gets some use from it.
Here's another use case with a workaround.

**Use case:**
```typescript
const { data: items, isFetching: itemsIsFetching, isSuccess: itemsIsSuccess } = useGetItemQuery();
const [ updateItemName ] = usePutItemNameMutation();
const [ updateItemDescription ] = usePutItemDescriptionMutation();

React.useEffect(() => {
    // Run only when fetching is complete
    if (itemsIsFetching || !itemsIsSuccess) return;
    // Perform actions based on the updated item list
    // ...
}, [itemsIsFetching]);

const onSave = async () => {
    await updateItemName("itemId", "someName");
    await updateItemDescription("itemId", "someDescription");
};
```
The `item` endpoint represents an object in the database. Each of its properties can be updated with a separate `PUT` method, which makes it easier to implement granular permissions - some users can update the description and other nested properties, while others cannot. Sometimes, we want to update two separate properties at once. This, however, results in a race condition where the re-fetch action is fired immediately after the first update. The second update is performed before the `GET` request finishes and doesn't trigger another refetch. This causes the `useEffect` hook to be fired after the first update, but before the second one, so it cannot access the updated `items[number].description` value. In this particular case, we don't have to send the updates sequentially, as they don't depend on each other, but that's beside the point.
We are using `@rtk-query/codegen-openapi` to generate and inject the endpoints, which makes it impossible to use the `queryFn` workaround.

**Workaround:**
```typescript
const [skip, setSkip] = React.useState(false);
const { data: items, isFetching: itemsIsFetching, isSuccess: itemsIsSuccess } = useGetItemQuery({ skip });
//...
const onSave = async () => {
    setSkip(true); // The items list is emptied
    await updateItemName("someName");
    await updateItemDescription("someDescription");
    setSkip(false); // Triggers a reload
};
```

There are many drawbacks to this workaround, but it works - it disables refetching of the item list while the updates are being applied, and then enables it, which causes the `useEffect` hook to fire after the second update. 

It would be best if we could batch multiple mutations, as proposed by [giladv](https://github.com/reduxjs/redux-toolkit/issues/2203#issuecomment-1173075261). 