diff --git a/.eslintrc b/.eslintrc
index 8fd53d54a183b..a8f7f1ae20697 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 691a65cc537cc..fdf974d117474 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 fd32bfd1613ee..9d9d2755eda74 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 069a3556da55f..bf2ed6111e84a 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 b5b49874276db..8112880f43235 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 74803f149e958..9724c81088304 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 e0765d59d30ed..2d7d737a00fa6 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}},
+			},
 		};
 	</script>
 	<link rel="shortcut icon" href="{{StaticUrlPrefix}}/img/favicon.png">
diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl
index de02bca1f7c58..cedf29e2e9c66 100644
--- a/templates/base/head_navbar.tmpl
+++ b/templates/base/head_navbar.tmpl
@@ -46,12 +46,11 @@
 				<span class="text">
 					<span class="fitted">{{svg "octicon-bell" 16}}</span>
 					<span class="sr-mobile-only">{{.i18n.Tr "notifications"}}</span>
-
-					{{if .NotificationUnreadCount}}
-						<span class="ui red label">
-							{{.NotificationUnreadCount}}
-						</span>
-					{{end}}
+					{{$notificationUnreadCount := 0}}
+					{{if .NotificationUnreadCount}}{{$notificationUnreadCount = call .NotificationUnreadCount}}{{end}}
+					<span class="ui red label {{if not $notificationUnreadCount}}hidden{{end}} notification_count">
+						{{$notificationUnreadCount}}
+					</span>
 				</span>
 			</a>
 
diff --git a/templates/user/notification/notification.tmpl b/templates/user/notification/notification.tmpl
index c4f744a291738..b483c15e95730 100644
--- a/templates/user/notification/notification.tmpl
+++ b/templates/user/notification/notification.tmpl
@@ -1,119 +1,3 @@
 {{template "base/head" .}}
-
-<div class="user notification">
-	<div class="ui container">
-		<h1 class="ui dividing header">{{.i18n.Tr "notification.notifications"}}</h1>
-
-		<div class="ui top attached tabular menu">
-			<a href="{{AppSubUrl}}/notifications?q=unread" class="{{if eq .Status 1}}active{{end}} item">
-				{{.i18n.Tr "notification.unread"}}
-				{{if .NotificationUnreadCount}}
-					<div class="ui label">{{.NotificationUnreadCount}}</div>
-				{{end}}
-			</a>
-			<a href="{{AppSubUrl}}/notifications?q=read" class="{{if eq .Status 2}}active{{end}} item">
-				{{.i18n.Tr "notification.read"}}
-			</a>
-			{{if and (eq .Status 1) (.NotificationUnreadCount)}}
-				<form action="{{AppSubUrl}}/notifications/purge" method="POST" style="margin-left: auto;">
-					{{$.CsrfTokenHtml}}
-					<button class="ui mini button primary" title='{{$.i18n.Tr "notification.mark_all_as_read"}}'>
-						{{svg "octicon-checklist" 16}}
-					</button>
-				</form>
-			{{end}}
-		</div>
-		<div class="ui bottom attached active tab segment">
-			{{if eq (len .Notifications) 0}}
-				{{if eq .Status 1}}
-					{{.i18n.Tr "notification.no_unread"}}
-				{{else}}
-					{{.i18n.Tr "notification.no_read"}}
-				{{end}}
-			{{else}}
-				<table class="ui unstackable striped very compact small selectable table">
-					<tbody>
-						{{range $notification := .Notifications}}
-							{{$issue := $notification.Issue}}
-							{{$repo := $notification.Repository}}
-							{{$repoOwner := $repo.MustOwner}}
-
-							<tr data-href="{{$notification.HTMLURL}}">
-								<td class="collapsing">
-									{{if eq $notification.Status 3}}
-										<span class="blue">{{svg "octicon-pin" 16}}</span>
-									{{else if $issue.IsPull}}
-										{{if $issue.IsClosed}}
-											{{if $issue.GetPullRequest.HasMerged}}
-												<span class="purple">{{svg "octicon-git-merge" 16}}</span>
-											{{else}}
-												<span class="red">{{svg "octicon-git-pull-request" 16}}</span>
-											{{end}}
-										{{else}}
-											<span class="green">{{svg "octicon-git-pull-request" 16}}</span>
-										{{end}}
-									{{else}}
-										{{if $issue.IsClosed}}
-											<span class="red">{{svg "octicon-issue-closed" 16}}</span>
-										{{else}}
-											<span class="green">{{svg "octicon-issue-opened" 16}}</span>
-										{{end}}
-									{{end}}
-								</td>
-								<td class="eleven wide">
-									<a class="item" href="{{$notification.HTMLURL}}">
-										#{{$issue.Index}} - {{$issue.Title}}
-									</a>
-								</td>
-								<td>
-									<a class="item" href="{{AppSubUrl}}/{{$repoOwner.Name}}/{{$repo.Name}}">
-										{{$repoOwner.Name}}/{{$repo.Name}}
-									</a>
-								</td>
-								<td class="collapsing">
-									{{if ne $notification.Status 3}}
-										<form action="{{AppSubUrl}}/notifications/status" method="POST">
-											{{$.CsrfTokenHtml}}
-											<input type="hidden" name="notification_id" value="{{$notification.ID}}" />
-											<input type="hidden" name="status" value="pinned" />
-											<button class="ui mini button" title='{{$.i18n.Tr "notification.pin"}}'>
-												{{svg "octicon-pin" 16}}
-											</button>
-										</form>
-									{{end}}
-								</td>
-								<td class="collapsing">
-									{{if or (eq $notification.Status 1) (eq $notification.Status 3)}}
-										<form action="{{AppSubUrl}}/notifications/status" method="POST">
-											{{$.CsrfTokenHtml}}
-											<input type="hidden" name="notification_id" value="{{$notification.ID}}" />
-											<input type="hidden" name="status" value="read" />
-											<input type="hidden" name="page" value="{{$.Page.Paginater.Current}}" />
-											<button class="ui mini button" title='{{$.i18n.Tr "notification.mark_as_read"}}'>
-												{{svg "octicon-check" 16}}
-											</button>
-										</form>
-									{{else if eq $notification.Status 2}}
-										<form action="{{AppSubUrl}}/notifications/status" method="POST">
-											{{$.CsrfTokenHtml}}
-											<input type="hidden" name="notification_id" value="{{$notification.ID}}" />
-											<input type="hidden" name="status" value="unread" />
-											<input type="hidden" name="page" value="{{$.Page.Paginater.Current}}" />
-											<button class="ui mini button" title='{{$.i18n.Tr "notification.mark_as_unread"}}'>
-												{{svg "octicon-bell" 16}}
-											</button>
-										</form>
-									{{end}}
-								</td>
-							</tr>
-						{{end}}
-					</tbody>
-				</table>
-			{{end}}
-		</div>
-
-		{{template "base/paginate" .}}
-	</div>
-</div>
-
+{{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 0000000000000..18054c479a4b0
--- /dev/null
+++ b/templates/user/notification/notification_div.tmpl
@@ -0,0 +1,128 @@
+<div class="user notification" id="notification_div" data-params="{{.Page.GetParams}}">
+	<div class="ui container">
+		<h1 class="ui dividing header">{{.i18n.Tr "notification.notifications"}}</h1>
+        <div class="ui top attached tabular menu">
+            {{ $notificationUnreadCount := call .NotificationUnreadCount}}
+            <a href="{{AppSubUrl}}/notifications?q=unread" class="{{if eq .Status 1}}active{{end}} item">
+                {{.i18n.Tr "notification.unread"}}
+                <div class="ui label {{if not $notificationUnreadCount}}hidden{{end}}">{{$notificationUnreadCount}}</div>
+            </a>
+            <a href="{{AppSubUrl}}/notifications?q=read" class="{{if eq .Status 2}}active{{end}} item">
+                {{.i18n.Tr "notification.read"}}
+            </a>
+            {{if and (eq .Status 1)}}
+                <form action="{{AppSubUrl}}/notifications/purge" method="POST" style="margin-left: auto;">
+                    {{$.CsrfTokenHtml}}
+                    <div class="{{if not $notificationUnreadCount}}hide{{end}}">
+                        <button class="ui mini button primary" title='{{$.i18n.Tr "notification.mark_all_as_read"}}'>
+                            {{svg "octicon-checklist" 16}}
+                        </button>
+                    </div>
+                </form>
+            {{end}}
+        </div>
+        <div class="ui bottom attached active tab segment">
+            {{if eq (len .Notifications) 0}}
+                {{if eq .Status 1}}
+                    {{.i18n.Tr "notification.no_unread"}}
+                {{else}}
+                    {{.i18n.Tr "notification.no_read"}}
+                {{end}}
+            {{else}}
+                <table class="ui unstackable striped very compact small selectable table" id="notification_table">
+                    <tbody>
+                        {{range $notification := .Notifications}}
+                            {{$issue := .Issue}}
+                            {{$repo := .Repository}}
+                            {{$repoOwner := $repo.MustOwner}}
+                            <tr id="notification_{{.ID}}">
+                                <td class="collapsing" data-href="{{.HTMLURL}}">
+                                    {{if eq .Status 3}}
+                                        <span class="blue">{{svg "octicon-pin" 16}}</span>
+                                    {{else if $issue.IsPull}}
+                                        {{if $issue.IsClosed}}
+                                            {{if $issue.GetPullRequest.HasMerged}}
+                                                <span class="purple">{{svg "octicon-git-merge" 16}}</span>
+                                            {{else}}
+                                                <span class="red">{{svg "octicon-git-pull-request" 16}}</span>
+                                            {{end}}
+                                        {{else}}
+                                            <span class="green">{{svg "octicon-git-pull-request" 16}}</span>
+                                        {{end}}
+                                    {{else}}
+                                        {{if $issue.IsClosed}}
+                                            <span class="red">{{svg "octicon-issue-closed" 16}}</span>
+                                        {{else}}
+                                            <span class="green">{{svg "octicon-issue-opened" 16}}</span>
+                                        {{end}}
+                                    {{end}}
+                                </td>
+                                <td class="eleven wide" data-href="{{.HTMLURL}}">
+                                    <a class="item" href="{{.HTMLURL}}">
+                                        #{{$issue.Index}} - {{$issue.Title}}
+                                    </a>
+                                </td>
+                                <td data-href="{{AppSubUrl}}/{{$repoOwner.Name}}/{{$repo.Name}}">
+                                    <a class="item" href="{{AppSubUrl}}/{{$repoOwner.Name}}/{{$repo.Name}}">
+                                        {{$repoOwner.Name}}/{{$repo.Name}}
+                                    </a>
+                                </td>
+                                <td class="collapsing">
+                                    {{if ne .Status 3}}
+                                        <form action="{{AppSubUrl}}/notifications/status" method="POST">
+                                            {{$.CsrfTokenHtml}}
+                                            <input type="hidden" name="notification_id" value="{{.ID}}" />
+                                            <input type="hidden" name="status" value="pinned" />
+                                            <button class="ui mini button" title='{{$.i18n.Tr "notification.pin"}}'
+                                                data-url="{{AppSubUrl}}/notifications/status"
+                                                data-status="pinned"
+                                                data-page="{{$.Page.Paginater.Current}}"
+                                                data-notification-id="{{.ID}}"
+                                                data-q="{{$.Keyword}}">
+                                                {{svg "octicon-pin" 16}}
+                                            </button>
+                                        </form>
+                                    {{end}}
+                                </td>
+                                <td class="collapsing">
+                                    {{if or (eq .Status 1) (eq .Status 3)}}
+                                        <form action="{{AppSubUrl}}/notifications/status" method="POST">
+                                            {{$.CsrfTokenHtml}}
+                                            <input type="hidden" name="notification_id" value="{{.ID}}" />
+                                            <input type="hidden" name="status" value="read" />
+                                            <input type="hidden" name="page" value="{{$.Page.Paginater.Current}}" />
+                                            <button class="ui mini button" title='{{$.i18n.Tr "notification.mark_as_read"}}'
+                                                data-url="{{AppSubUrl}}/notifications/status"
+                                                data-status="read"
+                                                data-page="{{$.Page.Paginater.Current}}"
+                                                data-notification-id="{{.ID}}"
+                                                data-q="{{$.Keyword}}">
+                                                {{svg "octicon-check" 16}}
+                                            </button>
+                                        </form>
+                                    {{else if eq .Status 2}}
+                                        <form action="{{AppSubUrl}}/notifications/status" method="POST">
+                                            {{$.CsrfTokenHtml}}
+                                            <input type="hidden" name="notification_id" value="{{.ID}}" />
+                                            <input type="hidden" name="status" value="unread" />
+                                            <input type="hidden" name="page" value="{{$.Page.Paginater.Current}}" />
+                                            <button class="ui mini button" title='{{$.i18n.Tr "notification.mark_as_unread"}}'
+                                                data-url="{{AppSubUrl}}/notifications/status"
+                                                data-status="unread"
+                                                data-page="{{$.Page.Paginater.Current}}"
+                                                data-notification-id="{{.ID}}"
+                                                data-q="{{$.Keyword}}">
+                                                {{svg "octicon-bell" 16}}
+                                            </button>
+                                        </form>
+                                    {{end}}
+                                </td>
+                            </tr>
+                        {{end}}
+                    </tbody>
+                </table>
+            {{end}}
+        </div>
+        {{template "base/paginate" .}}
+    </div>
+</div>
diff --git a/web_src/js/features/notification.js b/web_src/js/features/notification.js
new file mode 100644
index 0000000000000..3f2af4de91a0e
--- /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 ed747765a00fc..9e699c1a2e849 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 <td> 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) {