diff --git a/.eslintrc b/.eslintrc
index 8fd53d54a..a8f7f1ae2 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -55,6 +55,7 @@ rules:
   no-param-reassign: [0]
   no-plusplus: [0]
   no-restricted-syntax: [0]
+  no-return-await: [0]
   no-shadow: [0]
   no-unused-vars: [2, {args: all, argsIgnorePattern: ^_, varsIgnorePattern: ^_, ignoreRestSiblings: true}]
   no-use-before-define: [0]
diff --git a/custom/conf/app.ini.sample b/custom/conf/app.ini.sample
index 691a65cc5..fdf974d11 100644
--- a/custom/conf/app.ini.sample
+++ b/custom/conf/app.ini.sample
@@ -200,6 +200,14 @@ AUTHOR = Gitea - Git with a cup of tea
 DESCRIPTION = Gitea (Git with a cup of tea) is a painless self-hosted Git service written in Go
 KEYWORDS = go,git,self-hosted,gitea
 
+[ui.notification]
+; Control how often notification is queried to update the notification
+; The timeout will increase to MAX_TIMEOUT in TIMEOUT_STEPs if the notification count is unchanged
+; Set MIN_TIMEOUT to 0 to turn off
+MIN_TIMEOUT = 10s
+MAX_TIMEOUT = 60s
+TIMEOUT_STEP = 10s
+
 [markdown]
 ; Render soft line breaks as hard line breaks, which means a single newline character between
 ; paragraphs will cause a line break and adding trailing whitespace to paragraphs is not
diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
index fd32bfd16..9d9d2755e 100644
--- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md
+++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
@@ -140,6 +140,13 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
 - `NOTICE_PAGING_NUM`: **25**: Number of notices that are shown in one page.
 - `ORG_PAGING_NUM`: **50**: Number of organizations that are shown in one page.
 
+### UI - Notification (`ui.notification`)
+
+- `MIN_TIMEOUT`: **10s**: These options control how often notification is queried to update the notification count. On page load the notification count will be checked after `MIN_TIMEOUT`. The timeout will increase to `MAX_TIMEOUT` by `TIMEOUT_STEP` if the notification count is unchanged. Set MIN_TIMEOUT to 0 to turn off.
+- `MAX_TIMEOUT`: **60s**.
+- `TIMEOUT_STEP`: **10s**.
+
+
 ## Markdown (`markdown`)
 
 - `ENABLE_HARD_LINE_BREAK`: **true**: Render soft line breaks as hard line breaks, which
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index 069a3556d..bf2ed6111 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -181,6 +181,12 @@ var (
 		SearchRepoDescription bool
 		UseServiceWorker      bool
 
+		Notification struct {
+			MinTimeout  time.Duration
+			TimeoutStep time.Duration
+			MaxTimeout  time.Duration
+		} `ini:"ui.notification"`
+
 		Admin struct {
 			UserPagingNum   int
 			RepoPagingNum   int
@@ -209,6 +215,15 @@ var (
 		DefaultTheme:        `gitea`,
 		Themes:              []string{`gitea`, `arc-green`},
 		Reactions:           []string{`+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket`, `eyes`},
+		Notification: struct {
+			MinTimeout  time.Duration
+			TimeoutStep time.Duration
+			MaxTimeout  time.Duration
+		}{
+			MinTimeout:  10 * time.Second,
+			TimeoutStep: 10 * time.Second,
+			MaxTimeout:  60 * time.Second,
+		},
 		Admin: struct {
 			UserPagingNum   int
 			RepoPagingNum   int
diff --git a/modules/templates/helper.go b/modules/templates/helper.go
index b5b498742..8112880f4 100644
--- a/modules/templates/helper.go
+++ b/modules/templates/helper.go
@@ -278,6 +278,13 @@ func NewFuncMap() []template.FuncMap {
 				return ""
 			}
 		},
+		"NotificationSettings": func() map[string]int {
+			return map[string]int{
+				"MinTimeout":  int(setting.UI.Notification.MinTimeout / time.Millisecond),
+				"TimeoutStep": int(setting.UI.Notification.TimeoutStep / time.Millisecond),
+				"MaxTimeout":  int(setting.UI.Notification.MaxTimeout / time.Millisecond),
+			}
+		},
 		"contain": func(s []int64, id int64) bool {
 			for i := 0; i < len(s); i++ {
 				if s[i] == id {
diff --git a/routers/user/notification.go b/routers/user/notification.go
index 74803f149..9724c8108 100644
--- a/routers/user/notification.go
+++ b/routers/user/notification.go
@@ -7,6 +7,7 @@ package user
 import (
 	"errors"
 	"fmt"
+	"net/http"
 	"strconv"
 	"strings"
 
@@ -17,7 +18,8 @@ import (
 )
 
 const (
-	tplNotification base.TplName = "user/notification/notification"
+	tplNotification    base.TplName = "user/notification/notification"
+	tplNotificationDiv base.TplName = "user/notification/notification_div"
 )
 
 // GetNotificationCount is the middleware that sets the notification count in the context
@@ -30,17 +32,31 @@ func GetNotificationCount(c *context.Context) {
 		return
 	}
 
-	count, err := models.GetNotificationCount(c.User, models.NotificationStatusUnread)
-	if err != nil {
-		c.ServerError("GetNotificationCount", err)
-		return
-	}
+	c.Data["NotificationUnreadCount"] = func() int64 {
+		count, err := models.GetNotificationCount(c.User, models.NotificationStatusUnread)
+		if err != nil {
+			c.ServerError("GetNotificationCount", err)
+			return -1
+		}
 
-	c.Data["NotificationUnreadCount"] = count
+		return count
+	}
 }
 
 // Notifications is the notifications page
 func Notifications(c *context.Context) {
+	getNotifications(c)
+	if c.Written() {
+		return
+	}
+	if c.QueryBool("div-only") {
+		c.HTML(http.StatusOK, tplNotificationDiv)
+		return
+	}
+	c.HTML(http.StatusOK, tplNotification)
+}
+
+func getNotifications(c *context.Context) {
 	var (
 		keyword = strings.Trim(c.Query("q"), " ")
 		status  models.NotificationStatus
@@ -115,19 +131,13 @@ func Notifications(c *context.Context) {
 		c.Flash.Error(fmt.Sprintf("ERROR: %d notifications were removed due to missing parts - check the logs", failCount))
 	}
 
-	title := c.Tr("notifications")
-	if status == models.NotificationStatusUnread && total > 0 {
-		title = fmt.Sprintf("(%d) %s", total, title)
-	}
-	c.Data["Title"] = title
+	c.Data["Title"] = c.Tr("notifications")
 	c.Data["Keyword"] = keyword
 	c.Data["Status"] = status
 	c.Data["Notifications"] = notifications
 
 	pager.SetDefaultParams(c)
 	c.Data["Page"] = pager
-
-	c.HTML(200, tplNotification)
 }
 
 // NotificationStatusPost is a route for changing the status of a notification
@@ -155,8 +165,17 @@ func NotificationStatusPost(c *context.Context) {
 		return
 	}
 
-	url := fmt.Sprintf("%s/notifications?page=%s", setting.AppSubURL, c.Query("page"))
-	c.Redirect(url, 303)
+	if !c.QueryBool("noredirect") {
+		url := fmt.Sprintf("%s/notifications?page=%s", setting.AppSubURL, c.Query("page"))
+		c.Redirect(url, http.StatusSeeOther)
+	}
+
+	getNotifications(c)
+	if c.Written() {
+		return
+	}
+
+	c.HTML(http.StatusOK, tplNotificationDiv)
 }
 
 // NotificationPurgePost is a route for 'purging' the list of notifications - marking all unread as read
@@ -168,5 +187,5 @@ func NotificationPurgePost(c *context.Context) {
 	}
 
 	url := fmt.Sprintf("%s/notifications", setting.AppSubURL)
-	c.Redirect(url, 303)
+	c.Redirect(url, http.StatusSeeOther)
 }
diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl
index e0765d59d..2d7d737a0 100644
--- a/templates/base/head.tmpl
+++ b/templates/base/head.tmpl
@@ -94,6 +94,11 @@
 			U2F: {{if .RequireU2F}}true{{else}}false{{end}},
 			Heatmap: {{if .EnableHeatmap}}true{{else}}false{{end}},
 			heatmapUser: {{if .HeatmapUser}}'{{.HeatmapUser}}'{{else}}null{{end}},
+			NotificationSettings: {
+				MinTimeout: {{NotificationSettings.MinTimeout}},
+				TimeoutStep:  {{NotificationSettings.TimeoutStep}},
+				MaxTimeout: {{NotificationSettings.MaxTimeout}},
+			},
 		};
 	
 	
diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl
index de02bca1f..cedf29e2e 100644
--- a/templates/base/head_navbar.tmpl
+++ b/templates/base/head_navbar.tmpl
@@ -46,12 +46,11 @@
 				
 					{{svg "octicon-bell" 16}}
 					{{.i18n.Tr "notifications"}}
-
-					{{if .NotificationUnreadCount}}
-						
-							{{.NotificationUnreadCount}}
-						
-					{{end}}
+					{{$notificationUnreadCount := 0}}
+					{{if .NotificationUnreadCount}}{{$notificationUnreadCount = call .NotificationUnreadCount}}{{end}}
+					
+						{{$notificationUnreadCount}}
+					
 				
 			
 
diff --git a/templates/user/notification/notification.tmpl b/templates/user/notification/notification.tmpl
index c4f744a29..b483c15e9 100644
--- a/templates/user/notification/notification.tmpl
+++ b/templates/user/notification/notification.tmpl
@@ -1,119 +1,3 @@
 {{template "base/head" .}}
-
-
-	
-		
-
-		
-		
-			{{if eq (len .Notifications) 0}}
-				{{if eq .Status 1}}
-					{{.i18n.Tr "notification.no_unread"}}
-				{{else}}
-					{{.i18n.Tr "notification.no_read"}}
-				{{end}}
-			{{else}}
-				
-					
-						{{range $notification := .Notifications}}
-							{{$issue := $notification.Issue}}
-							{{$repo := $notification.Repository}}
-							{{$repoOwner := $repo.MustOwner}}
-
-							
-								| 
-									{{if eq $notification.Status 3}}
-										{{svg "octicon-pin" 16}}
-									{{else if $issue.IsPull}}
-										{{if $issue.IsClosed}}
-											{{if $issue.GetPullRequest.HasMerged}}
-												{{svg "octicon-git-merge" 16}}
-											{{else}}
-												{{svg "octicon-git-pull-request" 16}}
-											{{end}}
-										{{else}}
-											{{svg "octicon-git-pull-request" 16}}
-										{{end}}
-									{{else}}
-										{{if $issue.IsClosed}}
-											{{svg "octicon-issue-closed" 16}}
-										{{else}}
-											{{svg "octicon-issue-opened" 16}}
-										{{end}}
-									{{end}}
-								 | 
-								
-									
-										#{{$issue.Index}} - {{$issue.Title}}
-									
-								 | 
-								
-									
-										{{$repoOwner.Name}}/{{$repo.Name}}
-									
-								 | 
-								
-									{{if ne $notification.Status 3}}
-										
-									{{end}}
-								 | 
-								
-									{{if or (eq $notification.Status 1) (eq $notification.Status 3)}}
-										
-									{{else if eq $notification.Status 2}}
-										
-									{{end}}
-								 | 
-							
-						{{end}}
-					
-				
-			{{end}}
-		
-
-		{{template "base/paginate" .}}
-	
-
 
-
+{{template "user/notification/notification_div" .}}
 {{template "base/footer" .}}
diff --git a/templates/user/notification/notification_div.tmpl b/templates/user/notification/notification_div.tmpl
new file mode 100644
index 000000000..18054c479
--- /dev/null
+++ b/templates/user/notification/notification_div.tmpl
@@ -0,0 +1,128 @@
+
+	
+		
+        
+        
+            {{if eq (len .Notifications) 0}}
+                {{if eq .Status 1}}
+                    {{.i18n.Tr "notification.no_unread"}}
+                {{else}}
+                    {{.i18n.Tr "notification.no_read"}}
+                {{end}}
+            {{else}}
+                
+                    
+                        {{range $notification := .Notifications}}
+                            {{$issue := .Issue}}
+                            {{$repo := .Repository}}
+                            {{$repoOwner := $repo.MustOwner}}
+                            
+                                | 
+                                    {{if eq .Status 3}}
+                                        {{svg "octicon-pin" 16}}
+                                    {{else if $issue.IsPull}}
+                                        {{if $issue.IsClosed}}
+                                            {{if $issue.GetPullRequest.HasMerged}}
+                                                {{svg "octicon-git-merge" 16}}
+                                            {{else}}
+                                                {{svg "octicon-git-pull-request" 16}}
+                                            {{end}}
+                                        {{else}}
+                                            {{svg "octicon-git-pull-request" 16}}
+                                        {{end}}
+                                    {{else}}
+                                        {{if $issue.IsClosed}}
+                                            {{svg "octicon-issue-closed" 16}}
+                                        {{else}}
+                                            {{svg "octicon-issue-opened" 16}}
+                                        {{end}}
+                                    {{end}}
+                                 | 
+                                
+                                    
+                                        #{{$issue.Index}} - {{$issue.Title}}
+                                    
+                                 | 
+                                
+                                    
+                                        {{$repoOwner.Name}}/{{$repo.Name}}
+                                    
+                                 | 
+                                
+                                    {{if ne .Status 3}}
+                                        
+                                    {{end}}
+                                 | 
+                                
+                                    {{if or (eq .Status 1) (eq .Status 3)}}
+                                        
+                                    {{else if eq .Status 2}}
+                                        
+                                    {{end}}
+                                 | 
+                            
+                        {{end}}
+                    
+                
+            {{end}}
+        
+        {{template "base/paginate" .}}
+    
+
 
diff --git a/web_src/js/features/notification.js b/web_src/js/features/notification.js
new file mode 100644
index 000000000..3f2af4de9
--- /dev/null
+++ b/web_src/js/features/notification.js
@@ -0,0 +1,110 @@
+const {AppSubUrl, csrf, NotificationSettings} = window.config;
+
+export function initNotificationsTable() {
+  $('#notification_table .button').on('click', async function () {
+    const data = await updateNotification(
+      $(this).data('url'),
+      $(this).data('status'),
+      $(this).data('page'),
+      $(this).data('q'),
+      $(this).data('notification-id'),
+    );
+
+    $('#notification_div').replaceWith(data);
+    initNotificationsTable();
+    await updateNotificationCount();
+
+    return false;
+  });
+}
+
+export function initNotificationCount() {
+  if (NotificationSettings.MinTimeout <= 0) {
+    return;
+  }
+
+  const notificationCount = $('.notification_count');
+
+  if (notificationCount.length > 0) {
+    const fn = (timeout, lastCount) => {
+      setTimeout(async () => {
+        await updateNotificationCountWithCallback(fn, timeout, lastCount);
+      }, timeout);
+    };
+
+    fn(NotificationSettings.MinTimeout, notificationCount.text());
+  }
+}
+
+async function updateNotificationCountWithCallback(callback, timeout, lastCount) {
+  const currentCount = $('.notification_count').text();
+  if (lastCount !== currentCount) {
+    callback(NotificationSettings.MinTimeout, currentCount);
+    return;
+  }
+
+  const newCount = await updateNotificationCount();
+  let needsUpdate = false;
+
+  if (lastCount !== newCount) {
+    needsUpdate = true;
+    timeout = NotificationSettings.MinTimeout;
+  } else if (timeout < NotificationSettings.MaxTimeout) {
+    timeout += NotificationSettings.TimeoutStep;
+  }
+
+  callback(timeout, newCount);
+
+  const notificationDiv = $('#notification_div');
+  if (notificationDiv.length > 0 && needsUpdate) {
+    const data = await $.ajax({
+      type: 'GET',
+      url: `${AppSubUrl}/notifications?${notificationDiv.data('params')}`,
+      data: {
+        'div-only': true,
+      }
+    });
+    notificationDiv.replaceWith(data);
+    initNotificationsTable();
+  }
+}
+
+async function updateNotificationCount() {
+  const data = await $.ajax({
+    type: 'GET',
+    url: `${AppSubUrl}/api/v1/notifications/new`,
+    headers: {
+      'X-Csrf-Token': csrf,
+    },
+  });
+
+  const notificationCount = $('.notification_count');
+  if (data.new === 0) {
+    notificationCount.addClass('hidden');
+  } else {
+    notificationCount.removeClass('hidden');
+  }
+
+  notificationCount.text(`${data.new}`);
+
+  return `${data.new}`;
+}
+
+async function updateNotification(url, status, page, q, notificationID) {
+  if (status !== 'pinned') {
+    $(`#notification_${notificationID}`).remove();
+  }
+
+  return $.ajax({
+    type: 'POST',
+    url,
+    data: {
+      _csrf: csrf,
+      notification_id: notificationID,
+      status,
+      page,
+      q,
+      noredirect: true,
+    },
+  });
+}
diff --git a/web_src/js/index.js b/web_src/js/index.js
index ed747765a..9e699c1a2 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -18,6 +18,7 @@ import initDateTimePicker from './features/datetimepicker.js';
 import createDropzone from './features/dropzone.js';
 import highlight from './features/highlight.js';
 import ActivityTopAuthors from './components/ActivityTopAuthors.vue';
+import {initNotificationsTable, initNotificationCount} from './features/notification.js';
 
 const {AppSubUrl, StaticUrlPrefix, csrf} = window.config;
 
@@ -2431,6 +2432,11 @@ $(document).ready(async () => {
     window.location = $(this).data('href');
   });
 
+  // make table  element clickable like a link
+  $('td[data-href]').click(function () {
+    window.location = $(this).data('href');
+  });
+
   // Dropzone
   const $dropzone = $('#dropzone');
   if ($dropzone.length > 0) {
@@ -2606,6 +2612,8 @@ $(document).ready(async () => {
   initRepoStatusChecker();
   initTemplateSearch();
   initContextPopups();
+  initNotificationsTable();
+  initNotificationCount();
 
   // Repo clone url.
   if ($('#repo-clone-url').length > 0) {
 |