diff --git a/api/graphql/graph/root_.generated.go b/api/graphql/graph/root_.generated.go
index 8f6ae0152bef665a9a36cb1426253ebe4f19a93e..9ffbfa1f8f4e0aef0b02ec05c6769781292080cb 100644
--- a/api/graphql/graph/root_.generated.go
+++ b/api/graphql/graph/root_.generated.go
@@ -53,6 +53,7 @@ type ResolverRoot interface {
 	Mutation() MutationResolver
 	Query() QueryResolver
 	Repository() RepositoryResolver
+	Subscription() SubscriptionResolver
 }
 
 type DirectiveRoot struct {
@@ -115,6 +116,11 @@ type ComplexityRoot struct {
 		MessageIsEmpty func(childComplexity int) int
 	}
 
+	BugChange struct {
+		Bug  func(childComplexity int) int
+		Type func(childComplexity int) int
+	}
+
 	BugChangeLabelPayload struct {
 		Bug              func(childComplexity int) int
 		ClientMutationID func(childComplexity int) int
@@ -371,6 +377,10 @@ type ComplexityRoot struct {
 		UserIdentity  func(childComplexity int) int
 		ValidLabels   func(childComplexity int, after *string, before *string, first *int, last *int) int
 	}
+
+	Subscription struct {
+		BugChanged func(childComplexity int, repoRef *string, query *string) int
+	}
 }
 
 type executableSchema struct {
@@ -683,6 +693,20 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.BugAddCommentTimelineItem.MessageIsEmpty(childComplexity), true
 
+	case "BugChange.bug":
+		if e.complexity.BugChange.Bug == nil {
+			break
+		}
+
+		return e.complexity.BugChange.Bug(childComplexity), true
+
+	case "BugChange.type":
+		if e.complexity.BugChange.Type == nil {
+			break
+		}
+
+		return e.complexity.BugChange.Type(childComplexity), true
+
 	case "BugChangeLabelPayload.bug":
 		if e.complexity.BugChangeLabelPayload.Bug == nil {
 			break
@@ -1780,6 +1804,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Repository.ValidLabels(childComplexity, args["after"].(*string), args["before"].(*string), args["first"].(*int), args["last"].(*int)), true
 
+	case "Subscription.bugChanged":
+		if e.complexity.Subscription.BugChanged == nil {
+			break
+		}
+
+		args, err := ec.field_Subscription_bugChanged_args(ctx, rawArgs)
+		if err != nil {
+			return 0, false
+		}
+
+		return e.complexity.Subscription.BugChanged(childComplexity, args["repoRef"].(*string), args["query"].(*string)), true
+
 	}
 	return 0, false
 }
@@ -1842,6 +1878,23 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
 			var buf bytes.Buffer
 			data.MarshalGQL(&buf)
 
+			return &graphql.Response{
+				Data: buf.Bytes(),
+			}
+		}
+	case ast.Subscription:
+		next := ec._Subscription(ctx, opCtx.Operation.SelectionSet)
+
+		var buf bytes.Buffer
+		return func(ctx context.Context) *graphql.Response {
+			buf.Reset()
+			data := next(ctx)
+
+			if data == nil {
+				return nil
+			}
+			data.MarshalGQL(&buf)
+
 			return &graphql.Response{
 				Data: buf.Bytes(),
 			}
@@ -2573,6 +2626,20 @@ type Mutation # See each entity mutations
     OPEN
     CLOSED
 }
+`, BuiltIn: false},
+	{Name: "../schema/subscription.graphql", Input: `type Subscription {
+  bugChanged(repoRef: String, query: String): BugChange!
+}
+
+enum ChangeType {
+  CREATED
+  UPDATED
+}
+
+type BugChange {
+  type: ChangeType!
+  bug: Bug!
+}
 `, BuiltIn: false},
 	{Name: "../schema/types.graphql", Input: `scalar CombinedId
 scalar Time
diff --git a/api/graphql/graph/subscription.generated.go b/api/graphql/graph/subscription.generated.go
new file mode 100644
index 0000000000000000000000000000000000000000..a6f99919a1ecd301343173228344cf056d81b035
--- /dev/null
+++ b/api/graphql/graph/subscription.generated.go
@@ -0,0 +1,382 @@
+// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
+
+package graph
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"strconv"
+	"sync/atomic"
+
+	"github.com/99designs/gqlgen/graphql"
+	"github.com/git-bug/git-bug/api/graphql/models"
+	"github.com/vektah/gqlparser/v2/ast"
+)
+
+// region    ************************** generated!.gotpl **************************
+
+type SubscriptionResolver interface {
+	BugChanged(ctx context.Context, repoRef *string, query *string) (<-chan *models.BugChange, error)
+}
+
+// endregion ************************** generated!.gotpl **************************
+
+// region    ***************************** args.gotpl *****************************
+
+func (ec *executionContext) field_Subscription_bugChanged_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
+	var err error
+	args := map[string]any{}
+	arg0, err := ec.field_Subscription_bugChanged_argsRepoRef(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["repoRef"] = arg0
+	arg1, err := ec.field_Subscription_bugChanged_argsQuery(ctx, rawArgs)
+	if err != nil {
+		return nil, err
+	}
+	args["query"] = arg1
+	return args, nil
+}
+func (ec *executionContext) field_Subscription_bugChanged_argsRepoRef(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["repoRef"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("repoRef"))
+	if tmp, ok := rawArgs["repoRef"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+func (ec *executionContext) field_Subscription_bugChanged_argsQuery(
+	ctx context.Context,
+	rawArgs map[string]any,
+) (*string, error) {
+	if _, ok := rawArgs["query"]; !ok {
+		var zeroVal *string
+		return zeroVal, nil
+	}
+
+	ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("query"))
+	if tmp, ok := rawArgs["query"]; ok {
+		return ec.unmarshalOString2ᚖstring(ctx, tmp)
+	}
+
+	var zeroVal *string
+	return zeroVal, nil
+}
+
+// endregion ***************************** args.gotpl *****************************
+
+// region    ************************** directives.gotpl **************************
+
+// endregion ************************** directives.gotpl **************************
+
+// region    **************************** field.gotpl *****************************
+
+func (ec *executionContext) _BugChange_type(ctx context.Context, field graphql.CollectedField, obj *models.BugChange) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_BugChange_type(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Type, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(models.ChangeType)
+	fc.Result = res
+	return ec.marshalNChangeType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐChangeType(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_BugChange_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "BugChange",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			return nil, errors.New("field of type ChangeType does not have child fields")
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _BugChange_bug(ctx context.Context, field graphql.CollectedField, obj *models.BugChange) (ret graphql.Marshaler) {
+	fc, err := ec.fieldContext_BugChange_bug(ctx, field)
+	if err != nil {
+		return graphql.Null
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = graphql.Null
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return obj.Bug, nil
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return graphql.Null
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return graphql.Null
+	}
+	res := resTmp.(models.BugWrapper)
+	fc.Result = res
+	return ec.marshalNBug2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugWrapper(ctx, field.Selections, res)
+}
+
+func (ec *executionContext) fieldContext_BugChange_bug(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "BugChange",
+		Field:      field,
+		IsMethod:   false,
+		IsResolver: false,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "id":
+				return ec.fieldContext_Bug_id(ctx, field)
+			case "humanId":
+				return ec.fieldContext_Bug_humanId(ctx, field)
+			case "status":
+				return ec.fieldContext_Bug_status(ctx, field)
+			case "title":
+				return ec.fieldContext_Bug_title(ctx, field)
+			case "labels":
+				return ec.fieldContext_Bug_labels(ctx, field)
+			case "author":
+				return ec.fieldContext_Bug_author(ctx, field)
+			case "createdAt":
+				return ec.fieldContext_Bug_createdAt(ctx, field)
+			case "lastEdit":
+				return ec.fieldContext_Bug_lastEdit(ctx, field)
+			case "actors":
+				return ec.fieldContext_Bug_actors(ctx, field)
+			case "participants":
+				return ec.fieldContext_Bug_participants(ctx, field)
+			case "comments":
+				return ec.fieldContext_Bug_comments(ctx, field)
+			case "timeline":
+				return ec.fieldContext_Bug_timeline(ctx, field)
+			case "operations":
+				return ec.fieldContext_Bug_operations(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type Bug", field.Name)
+		},
+	}
+	return fc, nil
+}
+
+func (ec *executionContext) _Subscription_bugChanged(ctx context.Context, field graphql.CollectedField) (ret func(ctx context.Context) graphql.Marshaler) {
+	fc, err := ec.fieldContext_Subscription_bugChanged(ctx, field)
+	if err != nil {
+		return nil
+	}
+	ctx = graphql.WithFieldContext(ctx, fc)
+	defer func() {
+		if r := recover(); r != nil {
+			ec.Error(ctx, ec.Recover(ctx, r))
+			ret = nil
+		}
+	}()
+	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
+		ctx = rctx // use context from middleware stack in children
+		return ec.resolvers.Subscription().BugChanged(rctx, fc.Args["repoRef"].(*string), fc.Args["query"].(*string))
+	})
+	if err != nil {
+		ec.Error(ctx, err)
+		return nil
+	}
+	if resTmp == nil {
+		if !graphql.HasFieldError(ctx, fc) {
+			ec.Errorf(ctx, "must not be null")
+		}
+		return nil
+	}
+	return func(ctx context.Context) graphql.Marshaler {
+		select {
+		case res, ok := <-resTmp.(<-chan *models.BugChange):
+			if !ok {
+				return nil
+			}
+			return graphql.WriterFunc(func(w io.Writer) {
+				w.Write([]byte{'{'})
+				graphql.MarshalString(field.Alias).MarshalGQL(w)
+				w.Write([]byte{':'})
+				ec.marshalNBugChange2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugChange(ctx, field.Selections, res).MarshalGQL(w)
+				w.Write([]byte{'}'})
+			})
+		case <-ctx.Done():
+			return nil
+		}
+	}
+}
+
+func (ec *executionContext) fieldContext_Subscription_bugChanged(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
+	fc = &graphql.FieldContext{
+		Object:     "Subscription",
+		Field:      field,
+		IsMethod:   true,
+		IsResolver: true,
+		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
+			switch field.Name {
+			case "type":
+				return ec.fieldContext_BugChange_type(ctx, field)
+			case "bug":
+				return ec.fieldContext_BugChange_bug(ctx, field)
+			}
+			return nil, fmt.Errorf("no field named %q was found under type BugChange", field.Name)
+		},
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			err = ec.Recover(ctx, r)
+			ec.Error(ctx, err)
+		}
+	}()
+	ctx = graphql.WithFieldContext(ctx, fc)
+	if fc.Args, err = ec.field_Subscription_bugChanged_args(ctx, field.ArgumentMap(ec.Variables)); err != nil {
+		ec.Error(ctx, err)
+		return fc, err
+	}
+	return fc, nil
+}
+
+// endregion **************************** field.gotpl *****************************
+
+// region    **************************** input.gotpl *****************************
+
+// endregion **************************** input.gotpl *****************************
+
+// region    ************************** interface.gotpl ***************************
+
+// endregion ************************** interface.gotpl ***************************
+
+// region    **************************** object.gotpl ****************************
+
+var bugChangeImplementors = []string{"BugChange"}
+
+func (ec *executionContext) _BugChange(ctx context.Context, sel ast.SelectionSet, obj *models.BugChange) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, bugChangeImplementors)
+
+	out := graphql.NewFieldSet(fields)
+	deferred := make(map[string]*graphql.FieldSet)
+	for i, field := range fields {
+		switch field.Name {
+		case "__typename":
+			out.Values[i] = graphql.MarshalString("BugChange")
+		case "type":
+			out.Values[i] = ec._BugChange_type(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		case "bug":
+			out.Values[i] = ec._BugChange_bug(ctx, field, obj)
+			if out.Values[i] == graphql.Null {
+				out.Invalids++
+			}
+		default:
+			panic("unknown field " + strconv.Quote(field.Name))
+		}
+	}
+	out.Dispatch(ctx)
+	if out.Invalids > 0 {
+		return graphql.Null
+	}
+
+	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
+
+	for label, dfs := range deferred {
+		ec.processDeferredGroup(graphql.DeferredGroup{
+			Label:    label,
+			Path:     graphql.GetPath(ctx),
+			FieldSet: dfs,
+			Context:  ctx,
+		})
+	}
+
+	return out
+}
+
+var subscriptionImplementors = []string{"Subscription"}
+
+func (ec *executionContext) _Subscription(ctx context.Context, sel ast.SelectionSet) func(ctx context.Context) graphql.Marshaler {
+	fields := graphql.CollectFields(ec.OperationContext, sel, subscriptionImplementors)
+	ctx = graphql.WithFieldContext(ctx, &graphql.FieldContext{
+		Object: "Subscription",
+	})
+	if len(fields) != 1 {
+		ec.Errorf(ctx, "must subscribe to exactly one stream")
+		return nil
+	}
+
+	switch fields[0].Name {
+	case "bugChanged":
+		return ec._Subscription_bugChanged(ctx, fields[0])
+	default:
+		panic("unknown field " + strconv.Quote(fields[0].Name))
+	}
+}
+
+// endregion **************************** object.gotpl ****************************
+
+// region    ***************************** type.gotpl *****************************
+
+func (ec *executionContext) marshalNBugChange2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugChange(ctx context.Context, sel ast.SelectionSet, v models.BugChange) graphql.Marshaler {
+	return ec._BugChange(ctx, sel, &v)
+}
+
+func (ec *executionContext) marshalNBugChange2ᚖgithubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐBugChange(ctx context.Context, sel ast.SelectionSet, v *models.BugChange) graphql.Marshaler {
+	if v == nil {
+		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
+			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
+		}
+		return graphql.Null
+	}
+	return ec._BugChange(ctx, sel, v)
+}
+
+func (ec *executionContext) unmarshalNChangeType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐChangeType(ctx context.Context, v any) (models.ChangeType, error) {
+	var res models.ChangeType
+	err := res.UnmarshalGQL(v)
+	return res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalNChangeType2githubᚗcomᚋgitᚑbugᚋgitᚑbugᚋapiᚋgraphqlᚋmodelsᚐChangeType(ctx context.Context, sel ast.SelectionSet, v models.ChangeType) graphql.Marshaler {
+	return v
+}
+
+// endregion ***************************** type.gotpl *****************************
diff --git a/api/graphql/handler.go b/api/graphql/handler.go
index e45738d270f820f070c4ace154ef8dcc262bc9a0..6504055f6db251232948e6b1e9152ec2120c64bf 100644
--- a/api/graphql/handler.go
+++ b/api/graphql/handler.go
@@ -23,6 +23,9 @@ type Handler struct {
 func NewHandler(mrc *cache.MultiRepoCache, errorOut io.Writer) Handler {
 	rootResolver := resolvers.NewRootResolver(mrc)
 	config := graph.Config{Resolvers: rootResolver}
+
+	// TODO? https://gqlgen.com/recipes/subscriptions/ says to configure a WS upgrader, but
+	// handler.NewDefaultServer doesn't do it.
 	h := handler.NewDefaultServer(graph.NewExecutableSchema(config))
 
 	if errorOut != nil {
diff --git a/api/graphql/models/gen_models.go b/api/graphql/models/gen_models.go
index 36c68c0e691ee52391805fbd8511d828541e5c09..04e0278c545c12bf474399f15e7f8cd0c1ce34f7 100644
--- a/api/graphql/models/gen_models.go
+++ b/api/graphql/models/gen_models.go
@@ -3,6 +3,11 @@
 package models
 
 import (
+	"bytes"
+	"fmt"
+	"io"
+	"strconv"
+
 	"github.com/git-bug/git-bug/entities/bug"
 	"github.com/git-bug/git-bug/entities/common"
 	"github.com/git-bug/git-bug/entity/dag"
@@ -84,6 +89,11 @@ type BugAddCommentPayload struct {
 	Operation *bug.AddCommentOperation `json:"operation"`
 }
 
+type BugChange struct {
+	Type ChangeType `json:"type"`
+	Bug  BugWrapper `json:"bug"`
+}
+
 type BugChangeLabelInput struct {
 	// A unique identifier for the client performing the mutation.
 	ClientMutationID *string `json:"clientMutationId,omitempty"`
@@ -308,3 +318,61 @@ type PageInfo struct {
 
 type Query struct {
 }
+
+type Subscription struct {
+}
+
+type ChangeType string
+
+const (
+	ChangeTypeCreated ChangeType = "CREATED"
+	ChangeTypeUpdated ChangeType = "UPDATED"
+)
+
+var AllChangeType = []ChangeType{
+	ChangeTypeCreated,
+	ChangeTypeUpdated,
+}
+
+func (e ChangeType) IsValid() bool {
+	switch e {
+	case ChangeTypeCreated, ChangeTypeUpdated:
+		return true
+	}
+	return false
+}
+
+func (e ChangeType) String() string {
+	return string(e)
+}
+
+func (e *ChangeType) UnmarshalGQL(v any) error {
+	str, ok := v.(string)
+	if !ok {
+		return fmt.Errorf("enums must be strings")
+	}
+
+	*e = ChangeType(str)
+	if !e.IsValid() {
+		return fmt.Errorf("%s is not a valid ChangeType", str)
+	}
+	return nil
+}
+
+func (e ChangeType) MarshalGQL(w io.Writer) {
+	fmt.Fprint(w, strconv.Quote(e.String()))
+}
+
+func (e *ChangeType) UnmarshalJSON(b []byte) error {
+	s, err := strconv.Unquote(string(b))
+	if err != nil {
+		return err
+	}
+	return e.UnmarshalGQL(s)
+}
+
+func (e ChangeType) MarshalJSON() ([]byte, error) {
+	var buf bytes.Buffer
+	e.MarshalGQL(&buf)
+	return buf.Bytes(), nil
+}
diff --git a/api/graphql/models/lazy_bug.go b/api/graphql/models/lazy_bug.go
index 7570b4eaa783698862270b750544d51990e792cd..daf8990882c9521ffad2eb915a9354637256d2a5 100644
--- a/api/graphql/models/lazy_bug.go
+++ b/api/graphql/models/lazy_bug.go
@@ -33,7 +33,7 @@ type BugWrapper interface {
 
 var _ BugWrapper = &lazyBug{}
 
-// lazyBug is a lazy-loading wrapper that fetch data from the cache (BugExcerpt) in priority,
+// lazyBug is a lazy-loading wrapper that fetches data from the cache (BugExcerpt) in priority,
 // and load the complete bug and snapshot only when necessary.
 type lazyBug struct {
 	cache   *cache.RepoCache
diff --git a/api/graphql/resolvers/root.go b/api/graphql/resolvers/root.go
index e0fd47eb331a1c782c4d8b773058e5b3d6a28248..e422a93f79646106e7ff4614a9bc0e907f5ec111 100644
--- a/api/graphql/resolvers/root.go
+++ b/api/graphql/resolvers/root.go
@@ -31,6 +31,12 @@ func (r RootResolver) Mutation() graph.MutationResolver {
 	}
 }
 
+func (r RootResolver) Subscription() graph.SubscriptionResolver {
+	return &subscriptionResolver{
+		cache: r.MultiRepoCache,
+	}
+}
+
 func (RootResolver) Color() graph.ColorResolver {
 	return &colorResolver{}
 }
diff --git a/api/graphql/resolvers/subscription.go b/api/graphql/resolvers/subscription.go
new file mode 100644
index 0000000000000000000000000000000000000000..72ea18aeb5306ea14e72d441dd3b85877680908e
--- /dev/null
+++ b/api/graphql/resolvers/subscription.go
@@ -0,0 +1,75 @@
+package resolvers
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/git-bug/git-bug/api/graphql/graph"
+	"github.com/git-bug/git-bug/api/graphql/models"
+	"github.com/git-bug/git-bug/cache"
+	"github.com/git-bug/git-bug/entities/bug"
+	"github.com/git-bug/git-bug/entity"
+)
+
+var _ graph.SubscriptionResolver = &subscriptionResolver{}
+
+type subscriptionResolver struct {
+	cache *cache.MultiRepoCache
+}
+
+func (s subscriptionResolver) BugChanged(ctx context.Context, repoRef *string, query *string) (<-chan *models.BugChange, error) {
+	var repo *cache.RepoCache
+	var err error
+
+	if repoRef == nil {
+		repo, err = s.cache.DefaultRepo()
+	} else {
+		repo, err = s.cache.ResolveRepo(*repoRef)
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	out := make(chan *models.BugChange)
+	sub := bugSubscription{out: out, repo: repo}
+	repo.RegisterObserver(bug.Typename, sub)
+
+	go func() {
+		<-ctx.Done()
+		repo.RegisterObserver(bug.Typename, sub)
+	}()
+
+	return out, nil
+}
+
+type bugSubscription struct {
+	out  chan *models.BugChange
+	repo *cache.RepoCache
+}
+
+func (bs bugSubscription) EntityCreated(_ string, id entity.Id) {
+	excerpt, err := bs.repo.Bugs().ResolveExcerpt(id)
+	if err != nil {
+		// Should never happen
+		fmt.Printf("bug in the cache: could not resolve excerpt for %s: %s\n", id, err)
+		return
+	}
+	bs.out <- &models.BugChange{
+		Type: models.ChangeTypeCreated,
+		Bug:  models.NewLazyBug(bs.repo, excerpt),
+	}
+}
+
+func (bs bugSubscription) EntityUpdated(_ string, id entity.Id) {
+	excerpt, err := bs.repo.Bugs().ResolveExcerpt(id)
+	if err != nil {
+		// Should never happen
+		fmt.Printf("bug in the cache: could not resolve excerpt for %s: %s\n", id, err)
+		return
+	}
+	bs.out <- &models.BugChange{
+		Type: models.ChangeTypeUpdated,
+		Bug:  models.NewLazyBug(bs.repo, excerpt),
+	}
+}
diff --git a/api/graphql/schema/subscription.graphql b/api/graphql/schema/subscription.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..eb73b7de66e4a7e5e1f852fec823eb727fd2e9f6
--- /dev/null
+++ b/api/graphql/schema/subscription.graphql
@@ -0,0 +1,13 @@
+type Subscription {
+  bugChanged(repoRef: String, query: String): BugChange!
+}
+
+enum ChangeType {
+  CREATED
+  UPDATED
+}
+
+type BugChange {
+  type: ChangeType!
+  bug: Bug!
+}
diff --git a/cache/filter.go b/cache/filter.go
index 199e17b3380ce09652d76342d4cf9f43f9479bcb..c9e6059b8462a22e14837e8cab57bceddd8f452f 100644
--- a/cache/filter.go
+++ b/cache/filter.go
@@ -8,17 +8,17 @@ import (
 	"github.com/git-bug/git-bug/query"
 )
 
-// Filter is a predicate that match a subset of bugs
+// Filter is a predicate that matches a subset of bugs
 type Filter func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool
 
-// StatusFilter return a Filter that match a bug status
+// StatusFilter return a Filter that matches a bug status
 func StatusFilter(status common.Status) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		return excerpt.Status == status
 	}
 }
 
-// AuthorFilter return a Filter that match a bug author
+// AuthorFilter return a Filter that matches a bug author
 func AuthorFilter(query string) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		query = strings.ToLower(query)
@@ -32,7 +32,7 @@ func AuthorFilter(query string) Filter {
 	}
 }
 
-// MetadataFilter return a Filter that match a bug metadata at creation time
+// MetadataFilter return a Filter that matches a bug metadata at creation time
 func MetadataFilter(pair query.StringPair) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		if value, ok := excerpt.CreateMetadata[pair.Key]; ok {
@@ -42,7 +42,7 @@ func MetadataFilter(pair query.StringPair) Filter {
 	}
 }
 
-// LabelFilter return a Filter that match a label
+// LabelFilter return a Filter that matches a label
 func LabelFilter(label string) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		for _, l := range excerpt.Labels {
@@ -54,7 +54,7 @@ func LabelFilter(label string) Filter {
 	}
 }
 
-// ActorFilter return a Filter that match a bug actor
+// ActorFilter return a Filter that matches a bug actor
 func ActorFilter(query string) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		query = strings.ToLower(query)
@@ -73,7 +73,7 @@ func ActorFilter(query string) Filter {
 	}
 }
 
-// ParticipantFilter return a Filter that match a bug participant
+// ParticipantFilter return a Filter that matches a bug participant
 func ParticipantFilter(query string) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		query = strings.ToLower(query)
@@ -92,7 +92,7 @@ func ParticipantFilter(query string) Filter {
 	}
 }
 
-// TitleFilter return a Filter that match if the title contains the given query
+// TitleFilter return a Filter that matches if the title contains the given query
 func TitleFilter(query string) Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		return strings.Contains(
@@ -102,7 +102,7 @@ func TitleFilter(query string) Filter {
 	}
 }
 
-// NoLabelFilter return a Filter that match the absence of labels
+// NoLabelFilter return a Filter that matches the absence of labels
 func NoLabelFilter() Filter {
 	return func(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 		return len(excerpt.Labels) == 0
@@ -154,7 +154,7 @@ func compileMatcher(filters query.Filters) *Matcher {
 	return result
 }
 
-// Match check if a bug match the set of filters
+// Match check if a bug matches the set of filters
 func (f *Matcher) Match(excerpt *BugExcerpt, resolvers entity.Resolvers) bool {
 	if match := f.orMatch(f.Status, excerpt, resolvers); !match {
 		return false
diff --git a/cache/repo_cache.go b/cache/repo_cache.go
index eb441d9e6acc2d2843030aa19034584787235004..167ae743614643618195886819c42342993a5776 100644
--- a/cache/repo_cache.go
+++ b/cache/repo_cache.go
@@ -7,6 +7,8 @@ import (
 	"strconv"
 	"sync"
 
+	"github.com/git-bug/git-bug/entities/bug"
+	"github.com/git-bug/git-bug/entities/identity"
 	"github.com/git-bug/git-bug/entity"
 	"github.com/git-bug/git-bug/repository"
 	"github.com/git-bug/git-bug/util/multierr"
@@ -38,18 +40,29 @@ type cacheMgmt interface {
 	Close() error
 }
 
+// Observer gets notified of changes in entities in the cache
+type Observer interface {
+	// EntityCreated notifies that an entity has been created.
+	// The body of that function should NOT block.
+	EntityCreated(typename string, id entity.Id)
+
+	// EntityUpdated notifies that an entity has been updated.
+	// The body of that function should NOT block.
+	EntityUpdated(typename string, id entity.Id)
+}
+
 // RepoCache is a cache for a Repository. This cache has multiple functions:
 //
 //  1. After being loaded, a Bug is kept in memory in the cache, allowing for fast
 //     access later.
-//  2. The cache maintain in memory and on disk a pre-digested excerpt for each bug,
+//  2. The cache maintains in memory and on disk a pre-digested excerpt for each bug,
 //     allowing for fast querying the whole set of bugs without having to load
 //     them individually.
-//  3. The cache guarantee that a single instance of a Bug is loaded at once, avoiding
+//  3. The cache guarantees that a single instance of a Bug is loaded at once, avoiding
 //     loss of data that we could have with multiple copies in the same process.
-//  4. The same way, the cache maintain in memory a single copy of the loaded identities.
+//  4. The same way, the cache maintains in memory a single copy of the loaded identities.
 //
-// The cache also protect the on-disk data by locking the git repository for its
+// The cache also protects the on-disk data by locking the git repository for its
 // own usage, by writing a lock file. Of course, normal git operations are not
 // affected, only git-bug related one.
 type RepoCache struct {
@@ -101,7 +114,7 @@ func NewNamedRepoCache(r repository.ClockedRepo, name string) (*RepoCache, chan
 		&BugExcerpt{}:      entity.ResolverFunc[*BugExcerpt](c.bugs.ResolveExcerpt),
 	}
 
-	// small buffer so that below functions can emit an event without blocking
+	// small buffer so that the functions below can emit an event without blocking
 	events := make(chan BuildEvent)
 
 	go func() {
@@ -137,6 +150,28 @@ func NewRepoCacheNoEvents(r repository.ClockedRepo) (*RepoCache, error) {
 	return cache, nil
 }
 
+func (c *RepoCache) RegisterObserver(typename string, observer Observer) {
+	switch typename {
+	case bug.Typename:
+		c.bugs.RegisterObserver(observer)
+	case identity.Typename:
+		c.identities.RegisterObserver(observer)
+	default:
+		panic(fmt.Sprintf("unknown typename %q", typename))
+	}
+}
+
+func (c *RepoCache) UnregisterObserver(typename string, observer Observer) {
+	switch typename {
+	case bug.Typename:
+		c.bugs.UnregisterObserver(observer)
+	case identity.Typename:
+		c.identities.UnregisterObserver(observer)
+	default:
+		panic(fmt.Sprintf("unknown typename %q", typename))
+	}
+}
+
 // Bugs gives access to the Bug entities
 func (c *RepoCache) Bugs() *RepoCacheBug {
 	return c.bugs
@@ -224,15 +259,15 @@ const (
 
 // BuildEvent carry an event happening during the cache build process.
 type BuildEvent struct {
-	// Err carry an error if the build process failed. If set, no other field matter.
+	// Err carry an error if the build process failed. If set, no other field matters.
 	Err error
-	// Typename is the name of the entity of which the event relate to. Can be empty if not particular entity is involved.
+	// Typename is the name of the entity of which the event relate to. Can be empty if no particular entity is involved.
 	Typename string
 	// Event is the type of the event.
 	Event BuildEventType
-	// Total is the total number of element being built. Set if Event is BuildEventStarted.
+	// Total is the total number of elements being built. Set if Event is BuildEventStarted.
 	Total int64
-	// Progress is the current count of processed element. Set if Event is BuildEventProgress.
+	// Progress is the current count of processed elements. Set if Event is BuildEventProgress.
 	Progress int64
 }
 
diff --git a/cache/subcache.go b/cache/subcache.go
index d9b6db8d4e875c8a62920bec05f3f491bc081325..c5c3a88d2bc167a853d71760609c226bee97b0d2 100644
--- a/cache/subcache.go
+++ b/cache/subcache.go
@@ -59,6 +59,9 @@ type SubCache[EntityT entity.Interface, ExcerptT Excerpt, CacheT CacheEntity] st
 	excerpts map[entity.Id]ExcerptT
 	cached   map[entity.Id]CacheT
 	lru      lruIdCache
+
+	muObservers sync.RWMutex
+	observers   map[Observer]struct{}
 }
 
 func NewSubCache[EntityT entity.Interface, ExcerptT Excerpt, CacheT CacheEntity](
@@ -332,6 +335,18 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) Close() error {
 	return nil
 }
 
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) RegisterObserver(observer Observer) {
+	sc.muObservers.Lock()
+	defer sc.muObservers.Unlock()
+	sc.observers[observer] = struct{}{}
+}
+
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) UnregisterObserver(observer Observer) {
+	sc.muObservers.Lock()
+	defer sc.muObservers.Unlock()
+	delete(sc.observers, observer)
+}
+
 // AllIds return all known bug ids
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) AllIds() []entity.Id {
 	sc.mu.RLock()
@@ -460,7 +475,7 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) add(e EntityT) (CacheT, error) {
 	sc.evictIfNeeded()
 
 	// force the write of the excerpt
-	err := sc.entityUpdated(e.Id())
+	err := sc.entityCreated(e.Id())
 	if err != nil {
 		return *new(CacheT), err
 	}
@@ -582,8 +597,28 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) GetNamespace() string {
 	return sc.namespace
 }
 
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) entityCreated(id entity.Id) error {
+	sc.muObservers.RLock()
+	for observer := range sc.observers {
+		observer.EntityCreated(sc.typename, id)
+	}
+	sc.muObservers.RUnlock()
+
+	return sc.updateExcerptAndIndex(id)
+}
+
 // entityUpdated is a callback to trigger when the excerpt of an entity changed
 func (sc *SubCache[EntityT, ExcerptT, CacheT]) entityUpdated(id entity.Id) error {
+	sc.muObservers.RLock()
+	for observer := range sc.observers {
+		observer.EntityCreated(sc.typename, id)
+	}
+	sc.muObservers.RUnlock()
+
+	return sc.updateExcerptAndIndex(id)
+}
+
+func (sc *SubCache[EntityT, ExcerptT, CacheT]) updateExcerptAndIndex(id entity.Id) error {
 	sc.mu.Lock()
 	e, ok := sc.cached[id]
 	if !ok {
@@ -597,7 +632,6 @@ func (sc *SubCache[EntityT, ExcerptT, CacheT]) entityUpdated(id entity.Id) error
 		return errors.New("entity missing from cache")
 	}
 	sc.lru.Get(id)
-	// sc.excerpts[id] = bug2.NewBugExcerpt(b.bug, b.Snapshot())
 	sc.excerpts[id] = sc.makeExcerpt(e)
 	sc.mu.Unlock()
 
diff --git a/entities/bug/bug.go b/entities/bug/bug.go
index 8958fbd0e1b93498dd46631b3f3696fb421bf5b7..4dc533d8c72ecdc985a90bf76cbe7e17e99d1af4 100644
--- a/entities/bug/bug.go
+++ b/entities/bug/bug.go
@@ -38,7 +38,7 @@ type Interface interface {
 
 // Bug holds the data of a bug thread, organized in a way close to
 // how it will be persisted inside Git. This is the data structure
-// used to merge two different version of the same Bug.
+// used to merge two different versions of the same Bug.
 type Bug struct {
 	*dag.Entity
 }