diff --git a/Cargo.lock b/Cargo.lock
index 6ee6475..c1ec6c8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -59,6 +59,12 @@ version = "1.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
 
+[[package]]
+name = "arc-swap"
+version = "1.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
+
 [[package]]
 name = "arg_enum_proc_macro"
 version = "0.3.4"
@@ -403,6 +409,16 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
 
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
 [[package]]
 name = "cookie"
 version = "0.18.1"
@@ -2015,6 +2031,23 @@ dependencies = [
  "crossbeam-utils",
 ]
 
+[[package]]
+name = "redis"
+version = "0.29.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b110459d6e323b7cda23980c46c77157601199c9da6241552b284cd565a7a133"
+dependencies = [
+ "arc-swap",
+ "combine",
+ "itoa",
+ "num-bigint",
+ "percent-encoding",
+ "ryu",
+ "sha1_smol",
+ "socket2",
+ "url",
+]
+
 [[package]]
 name = "redox_syscall"
 version = "0.5.10"
@@ -2326,6 +2359,12 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "sha1_smol"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+
 [[package]]
 name = "sha2"
 version = "0.10.8"
@@ -2571,6 +2610,7 @@ dependencies = [
  "bb8-postgres",
  "bitflags 2.9.0",
  "pathbufd",
+ "redis",
  "rusqlite",
  "serde",
  "serde_json",
diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml
index 83b9312..9c7d5db 100644
--- a/crates/app/Cargo.toml
+++ b/crates/app/Cargo.toml
@@ -19,7 +19,7 @@ axum = { version = "0.8.1", features = ["macros"] }
 tokio = { version = "1.44.1", features = ["macros", "rt-multi-thread"] }
 axum-extra = { version = "0.10.0", features = ["cookie", "multipart"] }
 tetratto-shared = { path = "../shared" }
-tetratto-core = { path = "../core", default-features = false }
+tetratto-core = { path = "../core", features = ["redis"], default-features = false }
 tetratto-l10n = { path = "../l10n" }
 
 image = "0.25.5"
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 795efac..109ab22 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -6,7 +6,8 @@ edition = "2024"
 [features]
 postgres = ["dep:tokio-postgres", "dep:bb8-postgres"]
 sqlite = ["dep:rusqlite"]
-default = ["sqlite"]
+redis = ["dep:redis"]
+default = ["sqlite", "redis"]
 
 [dependencies]
 pathbufd = "0.1.4"
@@ -16,6 +17,8 @@ tetratto-shared = { path = "../shared" }
 tetratto-l10n = { path = "../l10n" }
 serde_json = "1.0.140"
 
+redis = { version = "0.29.2", optional = true }
+
 rusqlite = { version = "0.34.0", optional = true }
 
 tokio-postgres = { version = "0.7.13", optional = true }
diff --git a/crates/core/src/cache/mod.rs b/crates/core/src/cache/mod.rs
new file mode 100644
index 0000000..acc006a
--- /dev/null
+++ b/crates/core/src/cache/mod.rs
@@ -0,0 +1,77 @@
+#![allow(async_fn_in_trait)]
+use serde::{Serialize, de::DeserializeOwned};
+
+pub const EXPIRE_AT: i64 = 3_600_000;
+
+#[allow(type_alias_bounds)]
+pub type TimedObject<T: Serialize + DeserializeOwned> = (i64, T);
+
+#[cfg(feature = "redis")]
+pub mod redis;
+
+#[cfg(not(feature = "redis"))]
+pub mod no_cache;
+
+/// A simple cache "database".
+pub trait Cache {
+    type Item;
+    type Client;
+
+    /// Create a new [`Cache`].
+    async fn new() -> Self;
+    /// Get a connection to the cache.
+    async fn get_con(&self) -> Self::Client;
+
+    /// Get a cache object by its identifier
+    ///
+    /// # Arguments
+    /// * `id` - `String` of the object's id
+    async fn get(&self, id: Self::Item) -> Option<String>;
+    /// Set a cache object by its identifier and content
+    ///
+    /// # Arguments
+    /// * `id` - `String` of the object's id
+    /// * `content` - `String` of the object's content
+    async fn set(&self, id: Self::Item, content: Self::Item) -> bool;
+    /// Update a cache object by its identifier and content
+    ///
+    /// # Arguments
+    /// * `id` - `String` of the object's id
+    /// * `content` - `String` of the object's content
+    async fn update(&self, id: Self::Item, content: Self::Item) -> bool;
+    /// Remove a cache object by its identifier
+    ///
+    /// # Arguments
+    /// * `id` - `String` of the object's id
+    async fn remove(&self, id: Self::Item) -> bool;
+    /// Remove a cache object by its identifier('s start)
+    ///
+    /// # Arguments
+    /// * `id` - `String` of the object's id('s start)
+    async fn remove_starting_with(&self, id: Self::Item) -> bool;
+    /// Increment a cache object by its identifier
+    ///
+    /// # Arguments
+    /// * `id` - `String` of the object's id
+    async fn incr(&self, id: Self::Item) -> bool;
+    /// Decrement a cache object by its identifier
+    ///
+    /// # Arguments
+    /// * `id` - `String` of the object's id
+    async fn decr(&self, id: Self::Item) -> bool;
+
+    /// Get a cache object by its identifier
+    ///
+    /// # Arguments
+    /// * `id` - `String` of the object's id
+    async fn get_timed<T: Serialize + DeserializeOwned>(
+        &self,
+        id: Self::Item,
+    ) -> Option<TimedObject<T>>;
+    /// Set a cache object by its identifier and content
+    ///
+    /// # Arguments
+    /// * `id` - `String` of the object's id
+    /// * `content` - `String` of the object's content
+    async fn set_timed<T: Serialize + DeserializeOwned>(&self, id: Self::Item, content: T) -> bool;
+}
diff --git a/crates/core/src/cache/no_cache.rs b/crates/core/src/cache/no_cache.rs
new file mode 100644
index 0000000..c6022c6
--- /dev/null
+++ b/crates/core/src/cache/no_cache.rs
@@ -0,0 +1,62 @@
+use serde::{Serialize, de::DeserializeOwned};
+
+use super::{Cache, EXPIRE_AT, TimedObject};
+
+pub const EPOCH_YEAR: u32 = 2025;
+
+#[derive(Clone)]
+pub struct NoCache {
+    pub client: Option<u32>,
+}
+
+impl Cache for NoCache {
+    type Item = String;
+    type Client = Option<u32>;
+
+    async fn new() -> Self {
+        Self { client: None }
+    }
+
+    async fn get_con(&self) -> Self::Client {
+        None
+    }
+
+    async fn get(&self, id: Self::Item) -> Option<String> {
+        None
+    }
+
+    async fn set(&self, id: Self::Item, content: Self::Item) -> bool {
+        true
+    }
+
+    async fn update(&self, id: Self::Item, content: Self::Item) -> bool {
+        true
+    }
+
+    async fn remove(&self, id: Self::Item) -> bool {
+        true
+    }
+
+    async fn remove_starting_with(&self, id: Self::Item) -> bool {
+        true
+    }
+
+    async fn incr(&self, id: Self::Item) -> bool {
+        true
+    }
+
+    async fn decr(&self, id: Self::Item) -> bool {
+        true
+    }
+
+    async fn get_timed<T: Serialize + DeserializeOwned>(
+        &self,
+        id: Self::Item,
+    ) -> Option<TimedObject<T>> {
+        None
+    }
+
+    async fn set_timed<T: Serialize + DeserializeOwned>(&self, id: Self::Item, content: T) -> bool {
+        None
+    }
+}
diff --git a/crates/core/src/cache/redis.rs b/crates/core/src/cache/redis.rs
new file mode 100644
index 0000000..335c5e4
--- /dev/null
+++ b/crates/core/src/cache/redis.rs
@@ -0,0 +1,123 @@
+use redis::Commands;
+use serde::{Serialize, de::DeserializeOwned};
+
+use super::{Cache, EXPIRE_AT, TimedObject};
+
+pub const EPOCH_YEAR: u32 = 2025;
+
+#[derive(Clone)]
+pub struct RedisCache {
+    pub client: redis::Client,
+}
+
+impl Cache for RedisCache {
+    type Item = String;
+    type Client = redis::Connection;
+
+    async fn new() -> Self {
+        Self {
+            client: redis::Client::open("redis://127.0.0.1:6379").unwrap(),
+        }
+    }
+
+    async fn get_con(&self) -> Self::Client {
+        self.client.get_connection().unwrap()
+    }
+
+    async fn get(&self, id: Self::Item) -> Option<String> {
+        self.get_con().await.get(id).ok()
+    }
+
+    async fn set(&self, id: Self::Item, content: Self::Item) -> bool {
+        let mut c = self.get_con().await;
+        let res: Result<String, redis::RedisError> = c.set(id, content);
+
+        res.is_ok()
+    }
+
+    async fn update(&self, id: Self::Item, content: Self::Item) -> bool {
+        self.set(id, content).await
+    }
+
+    async fn remove(&self, id: Self::Item) -> bool {
+        let mut c = self.get_con().await;
+        let res: Result<String, redis::RedisError> = c.del(id);
+
+        res.is_ok()
+    }
+
+    async fn remove_starting_with(&self, id: Self::Item) -> bool {
+        let mut c = self.get_con().await;
+
+        // get keys
+        let mut cmd = redis::cmd("DEL");
+        let keys: Result<Vec<String>, redis::RedisError> = c.keys(id);
+
+        for key in keys.unwrap() {
+            cmd.arg(key);
+        }
+
+        // remove
+        let res: Result<String, redis::RedisError> = cmd.query(&mut c);
+
+        res.is_ok()
+    }
+
+    async fn incr(&self, id: Self::Item) -> bool {
+        let mut c = self.get_con().await;
+        let res: Result<String, redis::RedisError> = c.incr(id, 1);
+
+        res.is_ok()
+    }
+
+    async fn decr(&self, id: Self::Item) -> bool {
+        let mut c = self.get_con().await;
+        let res: Result<String, redis::RedisError> = c.decr(id, 1);
+
+        res.is_ok()
+    }
+
+    async fn get_timed<T: Serialize + DeserializeOwned>(
+        &self,
+        id: Self::Item,
+    ) -> Option<TimedObject<T>> {
+        let mut c = self.get_con().await;
+        let res: Result<String, redis::RedisError> = c.get(&id);
+
+        match res {
+            Ok(d) => match serde_json::from_str::<TimedObject<T>>(&d) {
+                Ok(d) => {
+                    // check time
+                    let now = tetratto_shared::epoch_timestamp(EPOCH_YEAR);
+
+                    if now - d.0 >= EXPIRE_AT {
+                        // expired key, remove and return None
+                        self.remove(id).await;
+                        return None;
+                    }
+
+                    // return
+                    Some(d)
+                }
+                Err(_) => None,
+            },
+            Err(_) => None,
+        }
+    }
+
+    async fn set_timed<T: Serialize + DeserializeOwned>(&self, id: Self::Item, content: T) -> bool {
+        let mut c = self.get_con().await;
+        let res: Result<String, redis::RedisError> = c.set(
+            id,
+            match serde_json::to_string::<TimedObject<T>>(&(
+                tetratto_shared::epoch_timestamp(EPOCH_YEAR),
+                content,
+            )) {
+                Ok(s) => s,
+                Err(_) => return false,
+            },
+        );
+
+        res.is_ok()
+    }
+}
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index bd58f91..cee3713 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -1,4 +1,5 @@
 use super::*;
+use crate::cache::Cache;
 use crate::model::{
     Error, Result,
     auth::{Token, User},
@@ -31,8 +32,8 @@ impl DataManager {
         }
     }
 
-    auto_method!(get_user_by_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE id = $1" --name="user" --returns=User);
-    auto_method!(get_user_by_username(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User);
+    auto_method!(get_user_by_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE id = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}");
+    auto_method!(get_user_by_username(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}");
 
     /// Get a user given just their auth token.
     ///
@@ -130,8 +131,11 @@ impl DataManager {
             return Err(Error::DatabaseError(e.to_string()));
         }
 
+        self.2.remove(format!("atto.user:{}", id)).await;
+        self.2.remove(format!("atto.user:{}", user.username)).await;
+
         Ok(())
     }
 
-    auto_method!(update_user_tokens(Vec<Token>) -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde);
+    auto_method!(update_user_tokens(Vec<Token>) -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.user:{}");
 }
diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs
index da6ced3..76188e8 100644
--- a/crates/core/src/database/common.rs
+++ b/crates/core/src/database/common.rs
@@ -39,6 +39,31 @@ macro_rules! auto_method {
         }
     };
 
+    ($name:ident()@$select_fn:ident -> $query:literal --name=$name_:literal --returns=$returns_:tt --cache-key-tmpl=$cache_key_tmpl:literal) => {
+        pub async fn $name(&self, id: usize) -> Result<$returns_> {
+            let conn = match self.connect().await {
+                Ok(c) => c,
+                Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+            };
+
+            let res = query_row!(&conn, $query, &[&id], |x| { Ok(Self::$select_fn(x)) });
+
+            if res.is_err() {
+                return Err(Error::GeneralNotFound($name_.to_string()));
+            }
+
+            let x = res.unwrap();
+            self.2
+                .set(
+                    format!($cache_key_tmpl, id),
+                    serde_json::to_string(&x).unwrap(),
+                )
+                .await;
+
+            Ok(x)
+        }
+    };
+
     ($name:ident($selector_t:ty)@$select_fn:ident -> $query:literal --name=$name_:literal --returns=$returns_:tt) => {
         pub async fn $name(&self, selector: $selector_t) -> Result<$returns_> {
             let conn = match self.connect().await {
@@ -56,6 +81,31 @@ macro_rules! auto_method {
         }
     };
 
+    ($name:ident($selector_t:ty)@$select_fn:ident -> $query:literal --name=$name_:literal --returns=$returns_:tt --cache-key-tmpl=$cache_key_tmpl:literal) => {
+        pub async fn $name(&self, selector: $selector_t) -> Result<$returns_> {
+            let conn = match self.connect().await {
+                Ok(c) => c,
+                Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+            };
+
+            let res = query_row!(&conn, $query, &[&selector], |x| { Ok(Self::$select_fn(x)) });
+
+            if res.is_err() {
+                return Err(Error::GeneralNotFound($name_.to_string()));
+            }
+
+            let x = res.unwrap();
+            self.2
+                .set(
+                    format!($cache_key_tmpl, selector),
+                    serde_json::to_string(&x).unwrap(),
+                )
+                .await;
+
+            Ok(x)
+        }
+    };
+
     ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal) => {
         pub async fn $name(&self, id: usize, user: User) -> Result<()> {
             let page = self.$select_fn(id).await?;
@@ -81,6 +131,33 @@ macro_rules! auto_method {
         }
     };
 
+    ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => {
+        pub async fn $name(&self, id: usize, user: User) -> Result<()> {
+            let page = self.$select_fn(id).await?;
+
+            if user.id != page.owner {
+                if !user.permissions.check(FinePermission::$permission) {
+                    return Err(Error::NotAllowed);
+                }
+            }
+
+            let conn = match self.connect().await {
+                Ok(c) => c,
+                Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+            };
+
+            let res = execute!(&conn, $query, &[&id.to_string()]);
+
+            if let Err(e) = res {
+                return Err(Error::DatabaseError(e.to_string()));
+            }
+
+            self.2.remove(format!($cache_key_tmpl, id)).await;
+
+            Ok(())
+        }
+    };
+
     ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal) => {
         pub async fn $name(&self, id: usize, user: User, x: $x) -> Result<()> {
             let y = self.$select_fn(id).await?;
@@ -106,6 +183,33 @@ macro_rules! auto_method {
         }
     };
 
+    ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => {
+        pub async fn $name(&self, id: usize, user: User, x: $x) -> Result<()> {
+            let y = self.$select_fn(id).await?;
+
+            if user.id != y.owner {
+                if !user.permissions.check(FinePermission::$permission) {
+                    return Err(Error::NotAllowed);
+                }
+            }
+
+            let conn = match self.connect().await {
+                Ok(c) => c,
+                Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+            };
+
+            let res = execute!(&conn, $query, &[&x, &id.to_string()]);
+
+            if let Err(e) = res {
+                return Err(Error::DatabaseError(e.to_string()));
+            }
+
+            self.2.remove(format!($cache_key_tmpl, id)).await;
+
+            Ok(())
+        }
+    };
+
     ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde) => {
         pub async fn $name(&self, id: usize, user: User, x: $x) -> Result<()> {
             let y = self.$select_fn(id).await?;
@@ -135,6 +239,37 @@ macro_rules! auto_method {
         }
     };
 
+    ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:literal) => {
+        pub async fn $name(&self, id: usize, user: User, x: $x) -> Result<()> {
+            let y = self.$select_fn(id).await?;
+
+            if user.id != y.owner {
+                if !user.permissions.check(FinePermission::$permission) {
+                    return Err(Error::NotAllowed);
+                }
+            }
+
+            let conn = match self.connect().await {
+                Ok(c) => c,
+                Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+            };
+
+            let res = execute!(
+                &conn,
+                $query,
+                &[&serde_json::to_string(&x).unwrap(), &id.to_string()]
+            );
+
+            if let Err(e) = res {
+                return Err(Error::DatabaseError(e.to_string()));
+            }
+
+            self.2.remove(format!($cache_key_tmpl, id)).await;
+
+            Ok(())
+        }
+    };
+
     ($name:ident($x:ty) -> $query:literal) => {
         pub async fn $name(&self, id: usize, x: $x) -> Result<()> {
             let conn = match self.connect().await {
@@ -152,6 +287,25 @@ macro_rules! auto_method {
         }
     };
 
+    ($name:ident($x:ty) -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => {
+        pub async fn $name(&self, id: usize, x: $x) -> Result<()> {
+            let conn = match self.connect().await {
+                Ok(c) => c,
+                Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+            };
+
+            let res = execute!(&conn, $query, &[&x, &id.to_string()]);
+
+            if let Err(e) = res {
+                return Err(Error::DatabaseError(e.to_string()));
+            }
+
+            self.2.remove(format!($cache_key_tmpl, id)).await;
+
+            Ok(())
+        }
+    };
+
     ($name:ident($x:ty) -> $query:literal --serde) => {
         pub async fn $name(&self, id: usize, x: $x) -> Result<()> {
             let conn = match self.connect().await {
@@ -172,4 +326,27 @@ macro_rules! auto_method {
             Ok(())
         }
     };
+
+    ($name:ident($x:ty) -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:literal) => {
+        pub async fn $name(&self, id: usize, x: $x) -> Result<()> {
+            let conn = match self.connect().await {
+                Ok(c) => c,
+                Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+            };
+
+            let res = execute!(
+                &conn,
+                $query,
+                &[&serde_json::to_string(&x).unwrap(), &id.to_string()]
+            );
+
+            if let Err(e) = res {
+                return Err(Error::DatabaseError(e.to_string()));
+            }
+
+            self.2.remove(format!($cache_key_tmpl, id)).await;
+
+            Ok(())
+        }
+    };
 }
diff --git a/crates/core/src/database/drivers/postgres.rs b/crates/core/src/database/drivers/postgres.rs
index ed1a6f1..bdd0e00 100644
--- a/crates/core/src/database/drivers/postgres.rs
+++ b/crates/core/src/database/drivers/postgres.rs
@@ -1,3 +1,10 @@
+#[cfg(not(feature = "redis"))]
+use crate::cache::no_cache::NoCache;
+#[cfg(feature = "redis")]
+use crate::cache::redis::RedisCache;
+
+use crate::cache::Cache;
+
 use crate::config::Config;
 use bb8_postgres::{
     PostgresConnectionManager,
@@ -13,13 +20,15 @@ pub type Connection<'a> = PooledConnection<'a, PostgresConnectionManager<NoTls>>
 pub struct DataManager(
     pub Config,
     pub HashMap<String, LangFile>,
+    #[cfg(feature = "redis")] pub RedisCache,
+    #[cfg(not(feature = "redis"))] pub NoCache,
     pub Pool<PostgresConnectionManager<NoTls>>,
 );
 
 impl DataManager {
     /// Obtain a connection to the staging database.
     pub(crate) async fn connect(&self) -> Result<Connection> {
-        Ok(self.2.get().await.unwrap())
+        Ok(self.3.get().await.unwrap())
     }
 
     /// Create a new [`DataManager`] (and init database).
@@ -36,7 +45,15 @@ impl DataManager {
         );
 
         let pool = Pool::builder().max_size(15).build(manager).await.unwrap();
-        Ok(Self(config.clone(), read_langs(), pool))
+        Ok(Self(
+            config.clone(),
+            read_langs(),
+            #[cfg(feature = "redis")]
+            RedisCache::new().await,
+            #[cfg(not(feature = "redis"))]
+            NoCache::new().await,
+            pool,
+        ))
     }
 }
 
diff --git a/crates/core/src/database/drivers/sqlite.rs b/crates/core/src/database/drivers/sqlite.rs
index cd07d80..b117387 100644
--- a/crates/core/src/database/drivers/sqlite.rs
+++ b/crates/core/src/database/drivers/sqlite.rs
@@ -1,10 +1,22 @@
+#[cfg(not(feature = "redis"))]
+use crate::cache::no_cache::NoCache;
+#[cfg(feature = "redis")]
+use crate::cache::redis::RedisCache;
+
+use crate::cache::Cache;
+
 use crate::config::Config;
 use rusqlite::{Connection, Result};
 use std::collections::HashMap;
 use tetratto_l10n::{LangFile, read_langs};
 
 #[derive(Clone)]
-pub struct DataManager(pub Config, pub HashMap<String, LangFile>);
+pub struct DataManager(
+    pub Config,
+    pub HashMap<String, LangFile>,
+    #[cfg(feature = "redis")] pub RedisCache,
+    #[cfg(not(feature = "redis"))] pub NoCache,
+);
 
 impl DataManager {
     /// Obtain a connection to the staging database.
@@ -14,7 +26,14 @@ impl DataManager {
 
     /// Create a new [`DataManager`] (and init database).
     pub async fn new(config: Config) -> Result<Self> {
-        let this = Self(config.clone(), read_langs());
+        let this = Self(
+            config.clone(),
+            read_langs(),
+            #[cfg(feature = "redis")]
+            RedisCache::new().await,
+            #[cfg(not(feature = "redis"))]
+            NoCache::new().await,
+        );
 
         let conn = this.connect().await?;
         conn.pragma_update(None, "journal_mode", "WAL").unwrap();
diff --git a/crates/core/src/database/pages.rs b/crates/core/src/database/pages.rs
index b258d81..d57023a 100644
--- a/crates/core/src/database/pages.rs
+++ b/crates/core/src/database/pages.rs
@@ -1,4 +1,5 @@
 use super::*;
+use crate::cache::Cache;
 use crate::model::auth::User;
 use crate::model::{Error, Result, journal::JournalPage, permissions::FinePermission};
 use crate::{auto_method, execute, get, query_row};
@@ -26,7 +27,7 @@ impl DataManager {
         }
     }
 
-    auto_method!(get_page_by_id()@get_page_from_row -> "SELECT * FROM pages WHERE id = $1" --name="journal page" --returns=JournalPage);
+    auto_method!(get_page_by_id()@get_page_from_row -> "SELECT * FROM pages WHERE id = $1" --name="journal page" --returns=JournalPage --cache-key-tmpl="atto.page:{}");
 
     /// Create a new journal page in the database.
     ///
@@ -77,9 +78,9 @@ impl DataManager {
         Ok(())
     }
 
-    auto_method!(delete_page()@get_page_by_id:MANAGE_JOURNAL_PAGES -> "DELETE FROM pages WHERE id = $1");
-    auto_method!(update_page_title(String)@get_page_by_id:MANAGE_JOURNAL_PAGES -> "UPDATE pages SET title = $1 WHERE id = $2");
-    auto_method!(update_page_prompt(String)@get_page_by_id:MANAGE_JOURNAL_PAGES -> "UPDATE pages SET prompt = $1 WHERE id = $2");
-    auto_method!(update_page_read_access(String)@get_page_by_id:MANAGE_JOURNAL_PAGES -> "UPDATE pages SET read_access = $1 WHERE id = $2" --serde);
-    auto_method!(update_page_write_access(String)@get_page_by_id:MANAGE_JOURNAL_PAGES -> "UPDATE pages SET write_access = $1 WHERE id = $2" --serde);
+    auto_method!(delete_page()@get_page_by_id:MANAGE_JOURNAL_PAGES -> "DELETE FROM pages WHERE id = $1" --cache-key-tmpl="atto.page:{}");
+    auto_method!(update_page_title(String)@get_page_by_id:MANAGE_JOURNAL_PAGES -> "UPDATE pages SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.page:{}");
+    auto_method!(update_page_prompt(String)@get_page_by_id:MANAGE_JOURNAL_PAGES -> "UPDATE pages SET prompt = $1 WHERE id = $2" --cache-key-tmpl="atto.page:{}");
+    auto_method!(update_page_read_access(String)@get_page_by_id:MANAGE_JOURNAL_PAGES -> "UPDATE pages SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.page:{}");
+    auto_method!(update_page_write_access(String)@get_page_by_id:MANAGE_JOURNAL_PAGES -> "UPDATE pages SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.page:{}");
 }
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index b3a148a..b94890d 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -1,3 +1,4 @@
+pub mod cache;
 pub mod config;
 pub mod database;
 pub mod model;
diff --git a/crates/shared/src/time.rs b/crates/shared/src/time.rs
index a2ad900..970b5ea 100644
--- a/crates/shared/src/time.rs
+++ b/crates/shared/src/time.rs
@@ -12,10 +12,10 @@ pub fn unix_epoch_timestamp() -> u128 {
 }
 
 /// Get a [`i64`] timestamp from the given `year` epoch
-pub fn epoch_timestamp(year: i32) -> i64 {
+pub fn epoch_timestamp(year: u32) -> i64 {
     let now = Utc::now().timestamp_millis();
     let then = Utc
-        .with_ymd_and_hms(year, 1, 1, 0, 0, 0)
+        .with_ymd_and_hms(year as i32, 1, 1, 0, 0, 0)
         .unwrap()
         .timestamp_millis();