diff --git a/src/mol-gl/webgl/compat.ts b/src/mol-gl/webgl/compat.ts
index 242fe0e40abd661e60e0641159e3bf885bdac741..6c26919119431e5609cae34333ccb71c3b670264 100644
--- a/src/mol-gl/webgl/compat.ts
+++ b/src/mol-gl/webgl/compat.ts
@@ -1,5 +1,5 @@
 /**
- * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
@@ -320,6 +320,87 @@ export function getSRGB(gl: GLRenderingContext): COMPAT_sRGB | null {
     }
 }
 
+export interface COMPAT_disjoint_timer_query {
+    /** A GLint indicating the number of bits used to hold the query result for the given target. */
+    QUERY_COUNTER_BITS: number
+    /** A WebGLQuery object, which is the currently active query for the given target. */
+    CURRENT_QUERY: number
+    /** A GLuint64EXT containing the query result. */
+    QUERY_RESULT: number
+    /** A GLboolean indicating whether or not a query result is available. */
+    QUERY_RESULT_AVAILABLE: number
+    /** Elapsed time (in nanoseconds). */
+    TIME_ELAPSED: number
+    /** The current time. */
+    TIMESTAMP: number
+    /** A GLboolean indicating whether or not the GPU performed any disjoint operation. */
+    GPU_DISJOINT: number
+
+    /** Creates a new WebGLTimerQueryEXT. */
+    createQuery: () => WebGLQuery
+    /** Deletes a given WebGLTimerQueryEXT. */
+    deleteQuery: (query: WebGLQuery) => void
+    /** Returns true if a given object is a valid WebGLTimerQueryEXT. */
+    isQuery: (query: WebGLQuery) => boolean
+    /** The timer starts when all commands prior to beginQueryEXT have been fully executed. */
+    beginQuery: (target: number, query: WebGLQuery) => void
+    /** The timer stops when all commands prior to endQueryEXT have been fully executed. */
+    endQuery: (target: number) => void
+    /** Records the current time into the corresponding query object. */
+    queryCounter: (query: WebGLQuery, target: number) => void
+    /** Returns information about a query target. */
+    getQuery: (target: number, pname: number) => WebGLQuery | number
+    /** Return the state of a query object. */
+    getQueryParameter: (query: WebGLQuery, pname: number) => number | boolean
+}
+
+export function getDisjointTimerQuery(gl: GLRenderingContext): COMPAT_disjoint_timer_query | null {
+    if (isWebGL2(gl)) {
+        // Firefox has EXT_disjoint_timer_query in webgl2
+        const ext = gl.getExtension('EXT_disjoint_timer_query_webgl2') || gl.getExtension('EXT_disjoint_timer_query');
+        if (ext === null) return null;
+        return {
+            QUERY_COUNTER_BITS: ext.QUERY_COUNTER_BITS_EXT,
+            CURRENT_QUERY: gl.CURRENT_QUERY,
+            QUERY_RESULT: gl.QUERY_RESULT,
+            QUERY_RESULT_AVAILABLE: gl.QUERY_RESULT_AVAILABLE,
+            TIME_ELAPSED: ext.TIME_ELAPSED_EXT,
+            TIMESTAMP: ext.TIMESTAMP_EXT,
+            GPU_DISJOINT: ext.GPU_DISJOINT_EXT,
+
+            createQuery: gl.createQuery.bind(gl),
+            deleteQuery: gl.deleteQuery.bind(gl),
+            isQuery: gl.isQuery.bind(gl),
+            beginQuery: gl.beginQuery.bind(gl),
+            endQuery: gl.endQuery.bind(gl),
+            queryCounter: ext.queryCounterEXT.bind(ext),
+            getQuery: gl.getQuery.bind(gl),
+            getQueryParameter: gl.getQueryParameter.bind(gl),
+        };
+    } else {
+        const ext = gl.getExtension('EXT_disjoint_timer_query');
+        if (ext === null) return null;
+        return {
+            QUERY_COUNTER_BITS: ext.QUERY_COUNTER_BITS_EXT,
+            CURRENT_QUERY: ext.CURRENT_QUERY_EXT,
+            QUERY_RESULT: ext.QUERY_RESULT_EXT,
+            QUERY_RESULT_AVAILABLE: ext.QUERY_RESULT_AVAILABLE_EXT,
+            TIME_ELAPSED: ext.TIME_ELAPSED_EXT,
+            TIMESTAMP: ext.TIMESTAMP_EXT,
+            GPU_DISJOINT: ext.GPU_DISJOINT_EXT,
+
+            createQuery: ext.createQueryEXT.bind(ext),
+            deleteQuery: ext.deleteQueryEXT.bind(ext),
+            isQuery: ext.isQueryEXT.bind(ext),
+            beginQuery: ext.beginQueryEXT.bind(ext),
+            endQuery: ext.endQueryEXT.bind(ext),
+            queryCounter: ext.queryCounterEXT.bind(ext),
+            getQuery: ext.getQueryEXT.bind(ext),
+            getQueryParameter: ext.getQueryObjectEXT.bind(ext),
+        };
+    }
+}
+
 //
 
 const TextureTestVertShader = `
diff --git a/src/mol-gl/webgl/context.ts b/src/mol-gl/webgl/context.ts
index 0c02300a24a14fa2ad463f240744146444989111..c3cd962a4f42716371db39688dcd4c66cd46ac22 100644
--- a/src/mol-gl/webgl/context.ts
+++ b/src/mol-gl/webgl/context.ts
@@ -17,6 +17,7 @@ import { BehaviorSubject } from 'rxjs';
 import { now } from '../../mol-util/now';
 import { Texture, TextureFilter } from './texture';
 import { ComputeRenderable } from '../renderable';
+import { createTimer, WebGLTimer } from './timer';
 
 export function getGLContext(canvas: HTMLCanvasElement, attribs?: WebGLContextAttributes & { preferWebGl1?: boolean }): GLRenderingContext | null {
     function get(id: 'webgl' | 'experimental-webgl' | 'webgl2') {
@@ -186,6 +187,7 @@ export interface WebGLContext {
     readonly state: WebGLState
     readonly stats: WebGLStats
     readonly resources: WebGLResources
+    readonly timer: WebGLTimer
 
     readonly maxTextureSize: number
     readonly max3dTextureSize: number
@@ -221,6 +223,7 @@ export function createContext(gl: GLRenderingContext, props: Partial<{ pixelScal
     const state = createState(gl);
     const stats = createStats();
     const resources = createResources(gl, state, stats, extensions);
+    const timer = createTimer(gl, extensions);
 
     const parameters = {
         maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE) as number,
@@ -289,6 +292,7 @@ export function createContext(gl: GLRenderingContext, props: Partial<{ pixelScal
         state,
         stats,
         resources,
+        timer,
 
         get maxTextureSize() { return parameters.maxTextureSize; },
         get max3dTextureSize() { return parameters.max3dTextureSize; },
diff --git a/src/mol-gl/webgl/extensions.ts b/src/mol-gl/webgl/extensions.ts
index 8c0949fcb83ac3f0272f83029baf4a7ea0480591..99cc66b4882219a7fbde4be99e752e6fb44c0893 100644
--- a/src/mol-gl/webgl/extensions.ts
+++ b/src/mol-gl/webgl/extensions.ts
@@ -1,10 +1,10 @@
 /**
- * Copyright (c) 2018-2021 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ * Copyright (c) 2018-2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
  *
  * @author Alexander Rose <alexander.rose@weirdbyte.de>
  */
 
-import { GLRenderingContext, COMPAT_instanced_arrays, COMPAT_standard_derivatives, COMPAT_vertex_array_object, getInstancedArrays, getStandardDerivatives, COMPAT_element_index_uint, getElementIndexUint, COMPAT_texture_float, getTextureFloat, COMPAT_texture_float_linear, getTextureFloatLinear, COMPAT_blend_minmax, getBlendMinMax, getFragDepth, COMPAT_frag_depth, COMPAT_color_buffer_float, getColorBufferFloat, COMPAT_draw_buffers, getDrawBuffers, getShaderTextureLod, COMPAT_shader_texture_lod, getDepthTexture, COMPAT_depth_texture, COMPAT_sRGB, getSRGB, getTextureHalfFloat, getTextureHalfFloatLinear, COMPAT_texture_half_float, COMPAT_texture_half_float_linear, COMPAT_color_buffer_half_float, getColorBufferHalfFloat, getVertexArrayObject } from './compat';
+import { GLRenderingContext, COMPAT_instanced_arrays, COMPAT_standard_derivatives, COMPAT_vertex_array_object, getInstancedArrays, getStandardDerivatives, COMPAT_element_index_uint, getElementIndexUint, COMPAT_texture_float, getTextureFloat, COMPAT_texture_float_linear, getTextureFloatLinear, COMPAT_blend_minmax, getBlendMinMax, getFragDepth, COMPAT_frag_depth, COMPAT_color_buffer_float, getColorBufferFloat, COMPAT_draw_buffers, getDrawBuffers, getShaderTextureLod, COMPAT_shader_texture_lod, getDepthTexture, COMPAT_depth_texture, COMPAT_sRGB, getSRGB, getTextureHalfFloat, getTextureHalfFloatLinear, COMPAT_texture_half_float, COMPAT_texture_half_float_linear, COMPAT_color_buffer_half_float, getColorBufferHalfFloat, getVertexArrayObject, getDisjointTimerQuery, COMPAT_disjoint_timer_query } from './compat';
 import { isDebugMode } from '../../mol-util/debug';
 
 export type WebGLExtensions = {
@@ -25,6 +25,7 @@ export type WebGLExtensions = {
     drawBuffers: COMPAT_draw_buffers | null
     shaderTextureLod: COMPAT_shader_texture_lod | null
     sRGB: COMPAT_sRGB | null
+    disjointTimerQuery: COMPAT_disjoint_timer_query | null
 }
 
 export function createExtensions(gl: GLRenderingContext): WebGLExtensions {
@@ -99,6 +100,10 @@ export function createExtensions(gl: GLRenderingContext): WebGLExtensions {
     if (isDebugMode && sRGB === null) {
         console.log('Could not find support for "sRGB"');
     }
+    const disjointTimerQuery = getDisjointTimerQuery(gl);
+    if (isDebugMode && disjointTimerQuery === null) {
+        console.log('Could not find support for "disjoint_timer_query"');
+    }
 
     return {
         instancedArrays,
@@ -118,5 +123,6 @@ export function createExtensions(gl: GLRenderingContext): WebGLExtensions {
         drawBuffers,
         shaderTextureLod,
         sRGB,
+        disjointTimerQuery,
     };
 }
\ No newline at end of file
diff --git a/src/mol-gl/webgl/timer.ts b/src/mol-gl/webgl/timer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..2479455bf5277a4021f3a2489999fb6088bc5f2f
--- /dev/null
+++ b/src/mol-gl/webgl/timer.ts
@@ -0,0 +1,194 @@
+/**
+ * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info.
+ *
+ * @author Alexander Rose <alexander.rose@weirdbyte.de>
+ */
+
+import { GLRenderingContext } from './compat';
+import { WebGLExtensions } from './extensions';
+
+export type TimerResult = {
+    readonly label: string
+    readonly timeElapsed: number
+    readonly children: TimerResult[]
+}
+
+function getQuery(extensions: WebGLExtensions) {
+    return extensions.disjointTimerQuery ? extensions.disjointTimerQuery.createQuery() : null;
+}
+
+export type WebGLTimer = {
+    /** Check with GPU for finished timers. */
+    resolve: () => TimerResult[]
+    mark: (label: string) => void
+    markEnd: (label: string) => void
+    clear: () => void
+    destroy: () => void
+}
+
+type Measure = { label: string, queries: WebGLQuery[], children: Measure[], root: boolean, timeElapsed?: number };
+type QueryResult = { timeElapsed?: number, refCount: number };
+
+export function createTimer(gl: GLRenderingContext, extensions: WebGLExtensions): WebGLTimer {
+    const dtq = extensions.disjointTimerQuery;
+
+    const queries = new Map<WebGLQuery, QueryResult>();
+    const pending = new Map<string, Measure>();
+    const stack: Measure[] = [];
+
+    let measures: Measure[] = [];
+    let current: WebGLQuery | null = null;
+
+    const clear = () => {
+        if (!dtq) return;
+
+        queries.forEach((_, query) => {
+            dtq.deleteQuery(query);
+        });
+        pending.clear();
+        measures = [];
+        current = null;
+    };
+
+    const add = () => {
+        if (!dtq) return;
+
+        const query = getQuery(extensions);
+        if (!query) return;
+
+        dtq.beginQuery(dtq.TIME_ELAPSED, query);
+        pending.forEach((measure, _) => {
+            measure.queries.push(query);
+        });
+        queries.set(query, { refCount: pending.size });
+        current = query;
+    };
+
+    return {
+        resolve: () => {
+            const results: TimerResult[] = [];
+            if (!dtq || !measures.length) return results;
+            // console.log('resolve');
+            queries.forEach((result, query) => {
+                if (result.timeElapsed !== undefined) return;
+
+                const available = dtq.getQueryParameter(query, dtq.QUERY_RESULT_AVAILABLE);
+                const disjoint = gl.getParameter(dtq.GPU_DISJOINT);
+
+                if (available && !disjoint) {
+                    const timeElapsed = dtq.getQueryParameter(query, dtq.QUERY_RESULT) as number;
+                    result.timeElapsed = timeElapsed;
+                    // console.log('timeElapsed', result.timeElapsed);
+                }
+
+                if (available || disjoint) {
+                    dtq.deleteQuery(query);
+                }
+            });
+
+            const unresolved: Measure[] = [];
+            for (const measure of measures) {
+                if (measure.queries.every(q => queries.get(q)?.timeElapsed !== undefined)) {
+                    let timeElapsed = 0;
+                    for (const query of measure.queries) {
+                        const result = queries.get(query)!;
+                        timeElapsed += result.timeElapsed!;
+                        result.refCount -= 1;
+                    }
+                    measure.timeElapsed = timeElapsed;
+                    if (measure.root) {
+                        const children: TimerResult[] = [];
+                        const add = (measures: Measure[], children: TimerResult[]) => {
+                            for (const measure of measures) {
+                                const result: TimerResult = {
+                                    label: measure.label,
+                                    timeElapsed: measure.timeElapsed!,
+                                    children: []
+                                };
+                                children.push(result);
+                                add(measure.children, result.children);
+                            }
+                        };
+                        add(measure.children, children);
+                        results.push({ label: measure.label, timeElapsed, children });
+                    }
+                } else {
+                    unresolved.push(measure);
+                }
+            }
+            measures = unresolved;
+
+            queries.forEach((result, query) => {
+                if (result.refCount === 0) {
+                    queries.delete(query);
+                }
+            });
+
+            return results;
+        },
+        mark: (label: string) => {
+            if (!dtq) return;
+
+            if (pending.has(label)) {
+                throw new Error(`Timer mark for '${label}' already exists`);
+            }
+
+            if (current !== null) {
+                dtq.endQuery(dtq.TIME_ELAPSED);
+            }
+            const measure: Measure = { label, queries: [], children: [], root: current === null };
+            pending.set(label, measure);
+
+            if (stack.length) {
+                stack[stack.length - 1].children.push(measure);
+            }
+            stack.push(measure);
+
+            add();
+        },
+        markEnd: (label: string) => {
+            if (!dtq) return;
+
+            const measure = pending.get(label);
+            if (!measure) {
+                throw new Error(`Timer mark for '${label}' does not exist`);
+            }
+
+            if (stack.pop()?.label !== label) {
+                throw new Error(`Timer mark for '${label}' has pending nested mark`);
+            }
+
+            dtq.endQuery(dtq.TIME_ELAPSED);
+            pending.delete(label);
+            measures.push(measure);
+
+            if (pending.size > 0) {
+                add();
+            } else {
+                current = null;
+            }
+        },
+        clear,
+        destroy: () => {
+            clear();
+        }
+    };
+}
+
+function formatTimerResult(result: TimerResult) {
+    const timeElapsed = result.timeElapsed / 1000 / 1000;
+    return `${result.label} ${timeElapsed.toFixed(2)}ms`;
+}
+
+export function printTimerResults(results: TimerResult[]) {
+    return results.map(r => {
+        const f = formatTimerResult(r);
+        if (r.children.length) {
+            console.groupCollapsed(f);
+            printTimerResults(r.children);
+            console.groupEnd();
+        } else {
+            console.log(f);
+        }
+    });
+}