diff --git a/playwright.config.ts b/playwright.config.ts
index d8a0e3ecf0..3906232abc 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -103,4 +103,5 @@ export default {
   outputDir: 'tests/e2e/test-artifacts/',
   /* Folder for explicit snapshots for visual testing */
   snapshotDir: 'tests/e2e/test-snapshots/',
+  snapshotPathTemplate: '{snapshotDir}/snapshots/{testFilePath}/{projectName}_{arg}{ext}',
 } satisfies PlaywrightTestConfig;
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
index cc255e05d9..8d8858bfd5 100644
--- a/tests/e2e/README.md
+++ b/tests/e2e/README.md
@@ -155,20 +155,6 @@ For SQLite:
 make test-e2e-sqlite#example
 ```
 
-### Visual testing
-
-> **Warning**
-> This is not currently used by most Forgejo contributors.
-> Your help to improve the situation and allow for visual testing is appreciated.
-
-Although the main goal of e2e is assertion testing, we have added a framework for visual regression testing. If you are working on front-end features, please use the following:
- - Check out `main`, `make clean frontend`, and run e2e tests with `VISUAL_TEST=1` to generate outputs. This will initially fail, as no screenshots exist. You can run the e2e tests again to assert that it passes.
- - Check out your branch, `make clean frontend`, and run e2e tests with `VISUAL_TEST=1`. You should be able to assert that your front-end changes don't break any other tests unintentionally.
-
-`VISUAL_TEST=1` will create screenshots in tests/e2e/test-snapshots. The test will fail the first time this is enabled (until we get visual test image persistence figured out), because it will be testing against an empty screenshot folder.
-
-`ACCEPT_VISUAL=1` will overwrite the snapshot images with new images.
-
 
 ## Tips and tricks
 
@@ -216,6 +202,41 @@ you can alternatively use:
 await page.waitForURL('**/target.html');
 ~~~
 
+### Visual testing
+
+Due to size and frequent updates, we do not host screenshots in the Forgejo repository.
+However, it is good practice to ensure that your test is capable of generating relevant and stable screenshots.
+Forgejo is regularly tested against visual regressions in a dedicated repository which contains the screenshots:
+https://code.forgejo.org/forgejo/visual-browser-testing/
+
+For tests that consume only the `page`,
+screenshots are automatically created at the end of each test.
+
+If your test visits different relevant screens or pages during the test,
+or creates a custom `page` from context
+(e.g. for tests that require a signed-in user)
+calling `await save_visual(page);` explicitly in relevant positions is encouraged.
+
+Please confirm locally that your screenshots are stable by performing several runs of your test.
+When screenshots are available and reproducible,
+check in your test without the screenshots.
+
+When your screenshots differ between runs,
+for example because dynamic elements (e.g. timestamps, commit hashes etc)
+change between runs,
+mask these elements in the `save_visual` function in `utils_e2e.ts`.
+
+#### Working with screenshots
+
+The following environment variables control visual testing:
+
+`VISUAL_TEST=1` will create screenshots in tests/e2e/test-snapshots.
+  The test will fail the first time,
+  because the screenshots are not included with Forgejo.
+  Subsequent runs will comopare against your local copy of the screenshots.
+
+`ACCEPT_VISUAL=1` will overwrite the snapshot images with new images.
+
 ### Only sign in if necessary
 
 Signing in takes time and is actually executed step-by-step.
diff --git a/tests/e2e/example.test.e2e.ts b/tests/e2e/example.test.e2e.ts
index 64818c4557..2405aee0c4 100644
--- a/tests/e2e/example.test.e2e.ts
+++ b/tests/e2e/example.test.e2e.ts
@@ -31,7 +31,7 @@ test('Register Form', async ({page}, workerInfo) => {
   await expect(page.locator('.secondary-nav span>img.ui.avatar')).toBeVisible();
   await expect(page.locator('.ui.positive.message.flash-success')).toHaveText('Account was successfully created. Welcome!');
 
-  save_visual(page);
+  await save_visual(page);
 });
 
 // eslint-disable-next-line playwright/no-skipped-test
diff --git a/tests/e2e/release.test.e2e.ts b/tests/e2e/release.test.e2e.ts
index 373f23dfa7..fefa446c59 100644
--- a/tests/e2e/release.test.e2e.ts
+++ b/tests/e2e/release.test.e2e.ts
@@ -41,7 +41,7 @@ test('External Release Attachments', async ({browser, isMobile}, workerInfo) =>
   await page.fill('input[name=attachment-new-name-2]', 'Test');
   await page.fill('input[name=attachment-new-exturl-2]', 'https://forgejo.org/');
   await page.click('.remove-rel-attach');
-  save_visual(page);
+  await save_visual(page);
   await page.click('.button.small.primary');
 
   // Validate release page and click edit
@@ -53,7 +53,7 @@ test('External Release Attachments', async ({browser, isMobile}, workerInfo) =>
   await expect(page.locator('.download[open] li:nth-of-type(2) a')).toHaveAttribute('href', '/user2/repo2/archive/2.0.tar.gz');
   await expect(page.locator('.download[open] li:nth-of-type(3)')).toContainText('Test');
   await expect(page.locator('.download[open] li:nth-of-type(3) a')).toHaveAttribute('href', 'https://forgejo.org/');
-  save_visual(page);
+  await save_visual(page);
   await page.locator('.octicon-pencil').first().click();
 
   // Validate edit page and edit the release
@@ -68,7 +68,7 @@ test('External Release Attachments', async ({browser, isMobile}, workerInfo) =>
   await expect(page.locator('.attachment_edit:visible')).toHaveCount(4);
   await page.locator('.attachment_edit:visible').nth(2).fill('Test3');
   await page.locator('.attachment_edit:visible').nth(3).fill('https://gitea.com/');
-  save_visual(page);
+  await save_visual(page);
   await page.click('.button.small.primary');
 
   // Validate release page and click edit
@@ -78,7 +78,7 @@ test('External Release Attachments', async ({browser, isMobile}, workerInfo) =>
   await expect(page.locator('.download[open] li:nth-of-type(3) a')).toHaveAttribute('href', 'https://gitea.io/');
   await expect(page.locator('.download[open] li:nth-of-type(4)')).toContainText('Test3');
   await expect(page.locator('.download[open] li:nth-of-type(4) a')).toHaveAttribute('href', 'https://gitea.com/');
-  save_visual(page);
+  await save_visual(page);
   await page.locator('.octicon-pencil').first().click();
 
   // Delete release
diff --git a/tests/e2e/utils_e2e.ts b/tests/e2e/utils_e2e.ts
index a52495bcc6..09189e6826 100644
--- a/tests/e2e/utils_e2e.ts
+++ b/tests/e2e/utils_e2e.ts
@@ -4,6 +4,15 @@ export const test = baseTest.extend({
   context: async ({browser}, use) => {
     return use(await test_context(browser));
   },
+  // see https://playwright.dev/docs/test-fixtures#adding-global-beforeeachaftereach-hooks
+  forEachTest: [async ({page}, use) => {
+    await use();
+    // some tests create a new page which is not yet available here
+    // only operate on tests that make the URL available
+    if (page.url() !== 'about:blank') {
+      await save_visual(page);
+    }
+  }, {auto: true}],
 });
 
 async function test_context(browser: Browser, options?: BrowserContextOptions) {
@@ -66,14 +75,28 @@ export async function save_visual(page: Page) {
   // Optionally include visual testing
   if (process.env.VISUAL_TEST) {
     await page.waitForLoadState('domcontentloaded');
-    // Mock page/version string
-    await page.locator('footer div.ui.left').evaluate((node) => node.innerHTML = 'MOCK');
+    // Mock/replace dynamic content which can have different size (and thus cannot simply be masked below)
+    await page.locator('footer .left-links').evaluate((node) => node.innerHTML = 'MOCK');
+    // replace timestamps in repos to mask them later down
+    await page.locator('.flex-item-body > relative-time').filter({hasText: /now|minute/}).evaluateAll((nodes) => {
+      for (const node of nodes) node.outerHTML = 'relative time in repo';
+    });
+    await page.locator('relative-time').evaluateAll((nodes) => {
+      for (const node of nodes) node.outerHTML = 'time element';
+    });
+    // used for instance for security keys
+    await page.locator('absolute-date').evaluateAll((nodes) => {
+      for (const node of nodes) node.outerHTML = 'time element';
+    });
     await expect(page).toHaveScreenshot({
       fullPage: true,
       timeout: 20000,
       mask: [
-        page.locator('.secondary-nav span>img.ui.avatar'),
-        page.locator('.ui.dropdown.jump.item span>img.ui.avatar'),
+        page.locator('.ui.avatar'),
+        page.locator('.sha'),
+        page.locator('#repo_migrating'),
+        // update order of recently created repos is not fully deterministic
+        page.locator('.flex-item-main').filter({hasText: 'relative time in repo'}),
       ],
     });
   }