diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java
index c6d0805fbca7..40b49f18e986 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/filters/ETagResponseFilter.java
@@ -16,6 +16,7 @@
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerResponseContext;
import jakarta.ws.rs.container.ContainerResponseFilter;
+import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.Provider;
import org.openmetadata.schema.EntityInterface;
@@ -23,23 +24,73 @@
import org.openmetadata.service.util.EntityETag;
/**
- * JAX-RS filter that automatically adds ETag headers to GET responses
- * containing EntityInterface entities.
+ * JAX-RS filter that adds {@code ETag} + {@code Cache-Control} headers to entity GET responses
+ * and short-circuits to {@code 304 Not Modified} when the client's {@code If-None-Match} matches
+ * the computed ETag.
+ *
+ *
The 304 path saves the response body bytes on the wire and the client-side render cost on
+ * revisits — the server still computes the entity body (we'd need a cheap version-stamp lookup
+ * to truly skip the work, see design doc), but the network and client savings are immediate.
+ *
+ *
{@code Cache-Control: must-revalidate, private}: clients (browsers, our Axios interceptor)
+ * may keep the body but must revalidate via {@code If-None-Match} before reusing it; private
+ * keeps it out of any shared/proxy cache so per-user data doesn't leak.
*/
@Provider
public class ETagResponseFilter implements ContainerResponseFilter {
+ private static final String CACHE_CONTROL_VALUE = "must-revalidate, private";
+
@Override
public void filter(
ContainerRequestContext requestContext, ContainerResponseContext responseContext) {
try (var ignored = RequestLatencyContext.phase("etagGeneration")) {
- if ("GET".equals(requestContext.getMethod())
- && responseContext.getStatus() == Response.Status.OK.getStatusCode()
- && responseContext.getEntity() instanceof EntityInterface entity) {
+ if (!"GET".equals(requestContext.getMethod())
+ || responseContext.getStatus() != Response.Status.OK.getStatusCode()
+ || !(responseContext.getEntity() instanceof EntityInterface entity)) {
+ return;
+ }
+
+ String etag = EntityETag.generateETag(entity);
+ if (etag == null) {
+ return;
+ }
+ responseContext.getHeaders().putSingle(HttpHeaders.ETAG, etag);
+ responseContext.getHeaders().putSingle(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL_VALUE);
+
+ String ifNoneMatch = requestContext.getHeaderString(HttpHeaders.IF_NONE_MATCH);
+ if (ifNoneMatch == null) {
+ return;
+ }
+ if (matchesAny(ifNoneMatch, etag)) {
+ // RFC 7232: 304 must NOT include a message body. Drop the entity so the
+ // serializer emits an empty body. Headers (including ETag) are preserved.
+ responseContext.setStatus(Response.Status.NOT_MODIFIED.getStatusCode());
+ responseContext.setEntity(null);
+ }
+ }
+ }
- String etag = EntityETag.generateETag(entity);
- responseContext.getHeaders().add("ETag", etag);
+ /**
+ * RFC 7232 §3.2: {@code If-None-Match} can be {@code *} (match any), a single ETag, or a
+ * comma-separated list. Weak comparison is used — we treat {@code "abc"} and {@code W/"abc"}
+ * as matching, which is the spec's recommendation for cache-validation use.
+ */
+ private static boolean matchesAny(String ifNoneMatch, String currentEtag) {
+ String trimmed = ifNoneMatch.trim();
+ if ("*".equals(trimmed)) {
+ return true;
+ }
+ String currentBare = stripWeakPrefix(currentEtag);
+ for (String candidate : trimmed.split(",")) {
+ if (currentBare.equals(stripWeakPrefix(candidate.trim()))) {
+ return true;
}
}
+ return false;
+ }
+
+ private static String stripWeakPrefix(String etag) {
+ return etag.startsWith("W/") ? etag.substring(2) : etag;
}
}
diff --git a/openmetadata-ui/src/main/resources/ui/jest.config.js b/openmetadata-ui/src/main/resources/ui/jest.config.js
index f9b94ac41c31..a0e1f01a2149 100644
--- a/openmetadata-ui/src/main/resources/ui/jest.config.js
+++ b/openmetadata-ui/src/main/resources/ui/jest.config.js
@@ -75,6 +75,16 @@ module.exports = {
'/src/test/unit/mocks/reactColumnResize.mock.js',
'^.*/Lineage/Layout/ELKUtil/ELKUtil$':
'/src/test/unit/mocks/elkLayout.mock.js',
+ // Force every `require('react')` / `require('react-dom')` to resolve to the consumer's
+ // copy. The `openmetadata-ui-core-components` package has its own `node_modules/react`
+ // (for its own dev/test) — without these mappings the CJS bundle loaded from
+ // `dist/*.cjs.js` resolves React from the core-components tree, producing a second React
+ // instance with a null hooks dispatcher and the classic "Invalid hook call ... reading
+ // 'useContext'" TypeError.
+ '^react$': '/node_modules/react',
+ '^react-dom$': '/node_modules/react-dom',
+ '^react/(.*)$': '/node_modules/react/$1',
+ '^react-dom/(.*)$': '/node_modules/react-dom/$1',
},
transformIgnorePatterns: [
'node_modules/(?!(@azure/msal-react|react-dnd|react-dnd-html5-backend|dnd-core|@react-dnd/invariant|@react-dnd/asap|@react-dnd/shallowequal|@melloware/react-logviewer|@material/material-color-utilities|@openmetadata/ui-core-components|nanoid|@rjsf/core|@rjsf/utils|@rjsf/validator-ajv8|uuid|elkjs))',
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx
index 74ecf1275491..f927e1d2a9da 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Auth/AuthProviders/AuthProvider.tsx
@@ -54,6 +54,7 @@ import { withDomainFilter } from '../../../hoc/withDomainFilter';
import { useApplicationStore } from '../../../hooks/useApplicationStore';
import useCustomLocation from '../../../hooks/useCustomLocation/useCustomLocation';
import axiosClient from '../../../rest';
+import { clearEtagCache } from '../../../rest/etagInterceptor';
import {
fetchAuthenticationConfig,
fetchAuthorizerConfig,
@@ -218,6 +219,10 @@ export const AuthProvider = ({
// Clear tokens properly during logout
await clearOidcToken();
+ // Drop the ETag interceptor's response cache so a freshly-authenticated user can't
+ // pick up another principal's cached body via If-None-Match → 304 mid-session.
+ clearEtagCache();
+
setApplicationLoading(false);
// Clear the refresh flag (used after refresh is complete)
diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts
new file mode 100644
index 000000000000..23ca33311f8a
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useDeferredTabData.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2026 Collate.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useEffect, useRef } from 'react';
+
+/**
+ * Fire {@code fetcher} the first time {@code activeTab} matches {@code tabKey}, and never
+ * again unless one of the {@code resetDeps} changes (in which case the hook arms itself for
+ * a fresh fetch on the next activation — or fires immediately if the gated tab is already
+ * the active one).
+ *
+ * Use case: tabs whose data drives a count badge (e.g. Queries (5), Tests (2)) AND whose
+ * fetch is independent of the entity-detail render. Most users never click these tabs, so
+ * eagerly fetching them on page load wastes a server round-trip per page view. Deferring
+ * the fetch until first activation moves the cost off the critical path.
+ *
+ * Caveat: the badge count won't appear before the user activates the tab. Render the tab
+ * label without the count until the fetch resolves; the count populates from the consumer's
+ * own state once the fetcher resolves.
+ *
+ * Re-arm semantics: when any {@code resetDeps} entry changes (typically the entity FQN), the
+ * hook treats the situation as "new entity, stale data". If the user is already on the gated
+ * tab when that change happens, we fire {@code fetcher} immediately rather than waiting for
+ * a tab toggle the user has no reason to do — otherwise the badge would show the previous
+ * entity's count until something forced a re-activation.
+ *
+ * @param tabKey The tab id this hook is gated on (e.g. 'queries').
+ * @param activeTab The currently-active tab id, typically from the URL or page state.
+ * @param fetcher Async function to run on first activation. Errors are swallowed by
+ * the caller's own try/catch — this hook just fires it.
+ * @param resetDeps Dependencies that should reset the "already fetched" flag. Typically
+ * includes the entity FQN so navigating to a different entity re-arms.
+ */
+export function useDeferredTabData(
+ tabKey: string,
+ activeTab: string | undefined,
+ fetcher: () => void | Promise,
+ resetDeps: ReadonlyArray = []
+): void {
+ const fetchedRef = useRef(false);
+ // Latest-fetcher ref so the resetDeps effect (which deliberately doesn't depend on
+ // {@code fetcher}) always calls the closure with up-to-date scope (e.g. tableFqn captured
+ // by the consumer).
+ const fetcherRef = useRef(fetcher);
+ fetcherRef.current = fetcher;
+
+ // Reset the once-flag when any reset dep changes — typically when the user navigates to
+ // a different entity, even if the tab id is the same. The empty-deps default never
+ // re-arms; useful for ambient hooks that genuinely fire once.
+ //
+ // If the gated tab is the currently-active tab at reset time, fire immediately so the
+ // badge updates for the new entity without waiting for a tab toggle. We set the flag to
+ // true *before* firing so the activation effect below doesn't double-fire on the same
+ // render cycle.
+ useEffect(() => {
+ fetchedRef.current = false;
+ if (activeTab === tabKey) {
+ fetchedRef.current = true;
+ void fetcherRef.current();
+ }
+ // `activeTab` and `tabKey` are read above but the intent is to fire only on resetDeps
+ // changes — including them in deps would cause every tab switch to also reset, which
+ // is the opposite of what we want.
+ }, resetDeps);
+
+ useEffect(() => {
+ if (activeTab !== tabKey || fetchedRef.current) {
+ return;
+ }
+ fetchedRef.current = true;
+ void fetcherRef.current();
+ // The fetcher closure changes on every render in most callers — depending on it would
+ // re-fire the fetch. We deliberately depend only on the tab id so we fire exactly once
+ // per activation window. `fetcherRef` keeps the latest closure available.
+ }, [activeTab, tabKey]);
+}
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx
index 966e5ead5f89..2cec32ec47eb 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx
@@ -56,6 +56,7 @@ import { TagLabel } from '../../generated/type/tagLabel';
import LimitWrapper from '../../hoc/LimitWrapper';
import { useApplicationStore } from '../../hooks/useApplicationStore';
import { useCustomPages } from '../../hooks/useCustomPages';
+import { useDeferredTabData } from '../../hooks/useDeferredTabData';
import { useFqn } from '../../hooks/useFqn';
import { useSub } from '../../hooks/usePubSub';
import { FeedCounts } from '../../interface/feed.interface';
@@ -785,13 +786,33 @@ const TableDetailsPageV1: React.FC = () => {
}
}, [tableFqn, isTourOpen, isTourPage, viewBasicPermission]);
+ // P1.2: getTestCaseFailureCount drives the global red-alert badge in the page chrome,
+ // so it must run as soon as tableDetails resolves — deferring would mean the user could
+ // miss a critical "this dataset has failing tests" indicator on first paint.
useEffect(() => {
if (tableDetails) {
- fetchQueryCount();
getTestCaseFailureCount();
}
}, [tableDetails?.fullyQualifiedName]);
+ // P1.2: queryCount only drives the "Queries (N)" tab badge — most users never click that
+ // tab, so eagerly fetching it on every page load wasted a server round-trip per view.
+ // Defer until the user actually activates the Queries tab (or any of its column-scoped
+ // sub-tabs); the badge then populates on first activation. {@link useDeferredTabData}
+ // also re-fires on FQN change if the user is already on the Queries tab, so badge counts
+ // never show stale data from a previous entity.
+ useDeferredTabData(EntityTabs.TABLE_QUERIES, activeTab, fetchQueryCount, [
+ tableDetails?.fullyQualifiedName,
+ ]);
+
+ // Reset the badge count to 0 when navigating to a different entity. Without this the
+ // badge would show the previous table's queryCount until the deferred fetch resolves,
+ // which is briefly misleading when navigating between tables that have differing query
+ // counts.
+ useEffect(() => {
+ setQueryCount(0);
+ }, [tableDetails?.fullyQualifiedName]);
+
useSub(
'updateDetails',
(suggestion: Suggestion) => {
diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts
new file mode 100644
index 000000000000..e6bb48d349a2
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/rest/etagInterceptor.ts
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2026 Collate.
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ AxiosHeaders,
+ AxiosInstance,
+ AxiosResponse,
+ InternalAxiosRequestConfig,
+} from 'axios';
+import Qs from 'qs';
+
+/**
+ * Client-side ETag / If-None-Match handling for entity GETs.
+ *
+ * Pairs with the server-side ETagResponseFilter which emits ETag + Cache-Control on entity
+ * GET responses and short-circuits to 304 when If-None-Match matches. The flow:
+ *
+ * 1. First GET to /tables/{fqn} → response with ETag header. We cache (etag, body) keyed
+ * by the canonical URL+params.
+ * 2. Second GET to the same URL → we attach If-None-Match. Server compares against the
+ * current ETag.
+ * - Match → 304 with empty body. Interceptor returns cached body as if it were 200.
+ * - No match → 200 with fresh body. Interceptor refreshes the cached entry.
+ *
+ * Wins: zero body bytes on the wire on revisit, plus skip JSON parse + render.
+ *
+ * Bounded memory: simple LRU cap at MAX_ENTRIES; oldest evicted on overflow. A typical entity
+ * GET response is 5-50 KB so the cap holds the cache to ~10 MB worst case.
+ */
+
+interface CachedEntry {
+ etag: string;
+ data: unknown;
+}
+
+const MAX_ENTRIES = 200;
+
+// Map preserves insertion order — re-set on hit to keep recently-used entries at the back.
+const etagCache = new Map();
+
+function buildKey(config: InternalAxiosRequestConfig): string {
+ const method = (config.method ?? 'get').toUpperCase();
+ const url = config.url ?? '';
+ const params = config.params
+ ? `?${Qs.stringify(config.params, { arrayFormat: 'comma' })}`
+ : '';
+
+ return `${method} ${url}${params}`;
+}
+
+function touch(key: string, entry: CachedEntry): void {
+ etagCache.delete(key);
+ etagCache.set(key, entry);
+
+ if (etagCache.size > MAX_ENTRIES) {
+ const oldest = etagCache.keys().next().value;
+ if (oldest !== undefined) {
+ etagCache.delete(oldest);
+ }
+ }
+}
+
+function readEtagHeader(response: AxiosResponse): string | undefined {
+ const headers = response.headers;
+ if (!headers) {
+ return undefined;
+ }
+
+ if (headers instanceof AxiosHeaders) {
+ const v = headers.get('etag');
+
+ return typeof v === 'string' ? v : undefined;
+ }
+
+ const candidate = (headers as Record).etag;
+ if (typeof candidate === 'string') {
+ return candidate;
+ }
+ const candidateUpper = (headers as Record).ETag;
+
+ return typeof candidateUpper === 'string' ? candidateUpper : undefined;
+}
+
+// Marker we stamp on an AxiosInstance once we've installed our interceptor pair. Lets the
+// function be properly idempotent — re-invocation (HMR, test setup re-runs, callers that
+// accidentally re-init) is a no-op rather than stacking another interceptor pair plus another
+// `validateStatus` override on top of itself.
+const ETAG_INTERCEPTOR_INSTALLED = Symbol.for(
+ '@openmetadata/etag-interceptor-installed'
+);
+
+/**
+ * Wire ETag handling into the axios client. Idempotent — calling twice on the same client is
+ * a no-op (guarded via a symbol marker on the instance). Callers that re-init axios from
+ * scratch should also clear the cache via {@link clearEtagCache}.
+ */
+export function attachEtagInterceptor(client: AxiosInstance): void {
+ const marker = client as unknown as Record;
+ if (marker[ETAG_INTERCEPTOR_INSTALLED]) {
+ return;
+ }
+ marker[ETAG_INTERCEPTOR_INSTALLED] = true;
+
+ // Treat 304 as a success status so axios delivers it through the response interceptor
+ // instead of the error path. Without this, our 304-handling code would have to live in
+ // the error interceptor and intercepts on every error path.
+ const previousValidate = client.defaults.validateStatus;
+ client.defaults.validateStatus = (status: number) =>
+ status === 304 ||
+ (previousValidate
+ ? previousValidate(status)
+ : status >= 200 && status < 300);
+
+ client.interceptors.request.use((config) => {
+ const method = (config.method ?? 'get').toLowerCase();
+ if (method !== 'get') {
+ return config;
+ }
+
+ const entry = etagCache.get(buildKey(config));
+ if (!entry) {
+ return config;
+ }
+
+ // Axios 1.x always populates config.headers with AxiosHeaders instance, but be
+ // defensive in case an upstream interceptor swapped it for a plain object.
+ if (config.headers instanceof AxiosHeaders) {
+ config.headers.set('If-None-Match', entry.etag);
+ } else if (config.headers) {
+ (config.headers as Record)['If-None-Match'] = entry.etag;
+ } else {
+ config.headers = new AxiosHeaders({ 'If-None-Match': entry.etag });
+ }
+
+ return config;
+ });
+
+ client.interceptors.response.use((response) => {
+ const method = (response.config.method ?? 'get').toLowerCase();
+ if (method !== 'get') {
+ return response;
+ }
+
+ const key = buildKey(response.config);
+
+ if (response.status === 304) {
+ const entry = etagCache.get(key);
+ if (entry) {
+ touch(key, entry);
+
+ // Deep-clone the cached body before handing it back. Consumers (UI components,
+ // utilities, edit handlers) routinely mutate the entity object they receive — adding
+ // local UI state, normalising fields, stripping properties — and a shared reference
+ // would let those mutations leak back into the cache. The next 304 would then return
+ // the mutated copy and cross-page bugs become very hard to track. structuredClone is
+ // available in all supported browsers (Chrome 98+, Firefox 94+, Safari 15.4+).
+ return {
+ ...response,
+ status: 200,
+ statusText: 'OK (from ETag cache)',
+ data: structuredClone(entry.data),
+ };
+ }
+
+ // 304 without a cached body shouldn't happen in normal flow — a stale interceptor
+ // attaching If-None-Match for a key we no longer hold. Bubble through; the caller
+ // sees 304 and decides. Better than fabricating a fake 200.
+ return response;
+ }
+
+ if (response.status === 200) {
+ const etag = readEtagHeader(response);
+ if (etag && response.data !== undefined) {
+ touch(key, { etag, data: response.data });
+ }
+ }
+
+ return response;
+ });
+}
+
+/**
+ * Drop every cached ETag entry. Call on logout / user switch so a freshly-authenticated user
+ * never receives another user's cached body via 304.
+ */
+export function clearEtagCache(): void {
+ etagCache.clear();
+}
+
+/** Test/debug helper. Returns the count of entries currently held. */
+export function etagCacheSize(): number {
+ return etagCache.size;
+}
diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/index.ts b/openmetadata-ui/src/main/resources/ui/src/rest/index.ts
index fd795dc40623..4110647d3577 100644
--- a/openmetadata-ui/src/main/resources/ui/src/rest/index.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/rest/index.ts
@@ -14,10 +14,17 @@
import axios from 'axios';
import Qs from 'qs';
import { getBasePath } from '../utils/HistoryUtils';
+import { attachEtagInterceptor } from './etagInterceptor';
const axiosClient = axios.create({
baseURL: `${getBasePath()}/api/v1`,
paramsSerializer: (params) => Qs.stringify(params, { arrayFormat: 'comma' }),
});
+// Client-side If-None-Match support paired with the server's ETagResponseFilter. Saves the
+// response body bytes + a JSON parse + a render on entity GET revisits within a session.
+// Attached here (before AuthProvider's interceptors) so it sits closest to the wire and
+// every other interceptor sees the resolved 304→200-with-cached-body translation.
+attachEtagInterceptor(axiosClient);
+
export default axiosClient;