diff --git a/packages/puppeteer-core/src/common/Operation.ts b/packages/puppeteer-core/src/common/Operation.ts
new file mode 100644
index 00000000000..3314641471f
--- /dev/null
+++ b/packages/puppeteer-core/src/common/Operation.ts
@@ -0,0 +1,153 @@
+/**
+ * Copyright 2023 Google Inc. All rights reserved.
+ *
+ * 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 type {Awaitable} from './types.js';
+
+/**
+ * Operations are promises that can have effects
+ * added on them (through {@link Operation.effect}).
+ *
+ * Semantically-speaking, adding an effect equates to guaranteeing the operation
+ * causes the added effect.
+ *
+ * The first effect that errors will propogate its error back to the operation.
+ *
+ * @example
+ *
+ * ```ts
+ * await input.click().effect(async () => {
+ * await page.waitForNavigation();
+ * });
+ * ```
+ *
+ * @remarks
+ *
+ * Adding effects to a completed operation will result in an error. This occurs
+ * when either
+ *
+ * 1. the effects are added asynchronously or
+ * 2. the operation was awaited before effects were added.
+ *
+ * For example for (1),
+ *
+ * ```ts
+ * const operation = input.click();
+ * await new Promise(resolve => setTimeout(resolve, 100));
+ * await operation.effect(() => console.log('Works!')); // This will throw because of (1).
+ * ```
+ *
+ * For example for (2),
+ *
+ * ```ts
+ * const operation = await input.click();
+ * await operation.effect(() => console.log('Works!')); // This will throw because of (2).
+ * ```
+ *
+ * Tl;dr, effects **must** be added synchronously (no `await` statements between
+ * the time the operation is created and the effect is added).
+ *
+ * @internal
+ */
+export class Operation extends Promise {
+ /**
+ * @internal
+ */
+ static create(fn: () => Awaitable, delay = 0): Operation {
+ return new Operation((resolve, reject) => {
+ setTimeout(async () => {
+ try {
+ resolve(await fn());
+ } catch (error) {
+ reject(error);
+ }
+ }, delay);
+ });
+ }
+
+ #settled = false;
+ #effects: Array> = [];
+ #error?: unknown;
+
+ /**
+ * Adds the given effect.
+ *
+ * @example
+ *
+ * ```ts
+ * await input.click().effect(async () => {
+ * await page.waitForNavigation();
+ * });
+ * ```
+ *
+ * @param effect - The effect to add.
+ * @returns `this` for chaining.
+ *
+ * @public
+ */
+ effect(effect: () => Awaitable): this {
+ if (this.#settled) {
+ throw new Error(
+ 'Attempted to add effect to a completed operation. Make sure effects are added synchronously after the operation is created.'
+ );
+ }
+ this.#effects.push(
+ (async () => {
+ try {
+ return await effect();
+ } catch (error) {
+ // Note we can't just push a rejected promise to #effects. This is because
+ // all rejections must be handled somewhere up in the call stack and since
+ // this function is synchronous, it is not handled anywhere in the call
+ // stack.
+ this.#error = error;
+ }
+ })()
+ );
+ return this;
+ }
+
+ get #effectsPromise(): Promise {
+ if (this.#error) {
+ return Promise.reject(this.#error);
+ }
+ return Promise.all(this.#effects);
+ }
+
+ override then(
+ onfulfilled?: (value: T) => TResult1 | PromiseLike,
+ onrejected?: (reason: any) => TResult2 | PromiseLike
+ ): Operation {
+ return super.then(
+ value => {
+ this.#settled = true;
+ return this.#effectsPromise.then(() => {
+ if (!onfulfilled) {
+ return value;
+ }
+ return onfulfilled(value);
+ }, onrejected);
+ },
+ reason => {
+ this.#settled = true;
+ if (!onrejected) {
+ throw reason;
+ }
+ return onrejected(reason);
+ }
+ ) as Operation;
+ }
+}
diff --git a/packages/puppeteer-core/src/common/common.ts b/packages/puppeteer-core/src/common/common.ts
index 99be593141e..e0cb10cbbc2 100644
--- a/packages/puppeteer-core/src/common/common.ts
+++ b/packages/puppeteer-core/src/common/common.ts
@@ -57,6 +57,7 @@ export * from './PredefinedNetworkConditions.js';
export * from './Product.js';
export * from './Puppeteer.js';
export * from './PuppeteerViewport.js';
+export * from './Operation.js';
export * from './SecurityDetails.js';
export * from './Target.js';
export * from './TargetManager.js';
diff --git a/test/src/operation.spec.ts b/test/src/operation.spec.ts
new file mode 100644
index 00000000000..b59e673009c
--- /dev/null
+++ b/test/src/operation.spec.ts
@@ -0,0 +1,234 @@
+/**
+ * Copyright 2023 Google Inc. All rights reserved.
+ *
+ * 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 expect from 'expect';
+import {Operation} from 'puppeteer-core/internal/common/Operation.js';
+
+describe('Operation', () => {
+ it('should work', async () => {
+ const values: number[] = [];
+
+ await Operation.create(() => {
+ values.push(1);
+ })
+ .effect(() => {
+ values.push(2);
+ })
+ .effect(() => {
+ values.push(3);
+ });
+
+ expect(values).toMatchObject([2, 3, 1]);
+ });
+
+ it('should work with error on operation', async () => {
+ const values: number[] = [];
+
+ let errored: string | undefined;
+ try {
+ await Operation.create(() => {
+ throw new Error('test');
+ })
+ .effect(() => {
+ values.push(1);
+ })
+ .effect(() => {
+ values.push(2);
+ });
+ } catch (error) {
+ errored = (error as Error).message;
+ }
+
+ expect(errored).toBe('test');
+ expect(values).toMatchObject([1, 2]);
+ });
+
+ it('should work with error on effect', async () => {
+ const values: number[] = [];
+
+ let errored: string | undefined;
+ try {
+ await Operation.create(() => {
+ values.push(1);
+ })
+ .effect(() => {
+ throw new Error('test');
+ })
+ .effect(() => {
+ values.push(2);
+ });
+ } catch (error) {
+ errored = (error as Error).message;
+ }
+
+ expect(errored).toBe('test');
+ expect(values).toMatchObject([2, 1]);
+ });
+
+ it('should work with error on both operation and effect', async () => {
+ const values: number[] = [];
+
+ let errored: string | undefined;
+ try {
+ await Operation.create(() => {
+ throw new Error('test1');
+ })
+ .effect(() => {
+ throw new Error('test2');
+ })
+ .effect(() => {
+ values.push(1);
+ });
+ } catch (error) {
+ errored = (error as Error).message;
+ }
+
+ expect(errored).toBe('test1');
+ expect(values).toMatchObject([1]);
+ });
+
+ it('should work with delayed error on operation', async () => {
+ const values: number[] = [];
+
+ let errored: string | undefined;
+ try {
+ await Operation.create(() => {
+ return new Promise((_, reject) => {
+ return setTimeout(() => {
+ reject(new Error('test1'));
+ }, 10);
+ });
+ })
+ .effect(() => {
+ throw new Error('test2');
+ })
+ .effect(() => {
+ values.push(1);
+ });
+ } catch (error) {
+ errored = (error as Error).message;
+ }
+
+ expect(errored).toBe('test1');
+ expect(values).toMatchObject([1]);
+ });
+
+ it('should work with async error on effects', async () => {
+ const values: number[] = [];
+
+ let errored: string | undefined;
+ try {
+ await Operation.create(() => {
+ values.push(1);
+ })
+ .effect(async () => {
+ throw new Error('test');
+ })
+ .effect(() => {
+ values.push(2);
+ });
+ } catch (error) {
+ errored = (error as Error).message;
+ }
+
+ expect(errored).toBe('test');
+ expect(values).toMatchObject([2, 1]);
+ });
+
+ it('should work with then', async () => {
+ const values: number[] = [];
+
+ const operation = Operation.create(() => {
+ values.push(1);
+ })
+ .effect(() => {
+ values.push(2);
+ })
+ .effect(() => {
+ values.push(3);
+ })
+ .then(() => {
+ values.push(4);
+ });
+ await operation;
+
+ expect(operation).toBeInstanceOf(Operation);
+ expect(values).toMatchObject([2, 3, 1, 4]);
+ });
+
+ it('should work with catch', async () => {
+ const values: number[] = [];
+
+ const operation = Operation.create(() => {
+ throw new Error('test');
+ })
+ .effect(() => {
+ values.push(1);
+ })
+ .effect(() => {
+ values.push(2);
+ })
+ .catch(() => {
+ values.push(3);
+ });
+ await operation;
+
+ expect(operation).toBeInstanceOf(Operation);
+ expect(values).toMatchObject([1, 2, 3]);
+ });
+
+ it('should work with finally', async () => {
+ const values: number[] = [];
+
+ const operation = Operation.create(() => {
+ values.push(1);
+ })
+ .effect(() => {
+ values.push(2);
+ })
+ .effect(() => {
+ values.push(3);
+ })
+ .finally(() => {
+ values.push(4);
+ });
+ await operation;
+
+ expect(operation).toBeInstanceOf(Operation);
+ expect(values).toMatchObject([2, 3, 1, 4]);
+ });
+
+ it('should throw when adding effects on on awaited operation', async () => {
+ const values: number[] = [];
+
+ const operation = Operation.create(() => {
+ values.push(1);
+ });
+ await operation;
+
+ expect(() => {
+ operation.effect(() => {
+ values.push(2);
+ });
+ }).toThrowError(
+ new Error(
+ 'Attempted to add effect to a completed operation. Make sure effects are added synchronously after the operation is created.'
+ )
+ );
+ expect(operation).toBeInstanceOf(Operation);
+ expect(values).toMatchObject([1]);
+ });
+});