diff --git a/sql/init_data.h2.sql b/sql/init_data.h2.sql
index 68699968eaa6a1c9a05a9144c728960e8dfe1a8a..6f76bd4597bb79fddab8ed69b8bb7eaa9fb55468 100644
--- a/sql/init_data.h2.sql
+++ b/sql/init_data.h2.sql
@@ -115,3 +115,8 @@ INSERT INTO normalvalue (indicator, cell, doy, computedvalue)
     JOIN indicator AS i ON i.code=t.indicator
     JOIN period AS p ON p.id=i.period
     WHERE p.code=t.period;
+
+-- simulation
+INSERT INTO simulation (date, simulationid, started, ended) VALUES
+	('2024-02-19', 1, '2024-02-20 12:00:00', '2024-02-20 12:30:00'),
+	('2024-02-20', 2, '2024-02-21 13:00:00', NULL);
\ No newline at end of file
diff --git a/sql/migration.sql b/sql/migration.sql
index 761d96da1832cf06ae29483359f738450159297c..9406e3adea6a51245d528f9b096731d1cbd9797e 100644
--- a/sql/migration.sql
+++ b/sql/migration.sql
@@ -144,6 +144,17 @@ END
 $BODY$
 language plpgsql;
 
+--
+-- 47
+--
+CREATE OR REPLACE FUNCTION upgrade20240220() RETURNS boolean AS $BODY$
+BEGIN
+	INSERT INTO simulation (date, simulationid, started, ended) VALUES
+		('2024-02-19', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
+	RETURN true;
+END
+$BODY$
+language plpgsql;
 ---
 --
 -- Keep this call at the end to apply migration functions.
diff --git a/sql/schema.tables.sql b/sql/schema.tables.sql
index 1c1fdd9bfe6fe2b568fef3b17b0de6a90a3ca1bf..ee92d5e62c86580e29827c9fb000932c4317e194 100644
--- a/sql/schema.tables.sql
+++ b/sql/schema.tables.sql
@@ -1,6 +1,16 @@
 -- Schema for AgroMetInfo database
 -- MUST be compatible with H2 and PostgreSQL
 
+CREATE TABLE IF NOT EXISTS simulation (
+    id SERIAL,
+    date DATE NOT NULL,
+    simulationid INTEGER NOT NULL,
+    started TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    ended TIMESTAMP,
+    CONSTRAINT "PK_simulation" PRIMARY KEY (id)
+);
+COMMENT ON TABLE simulation IS 'Simulation run to produce the values.';
+
 CREATE TABLE IF NOT EXISTS locale (
     id SERIAL,
     languagetag VARCHAR(20) NOT NULL,
diff --git a/src/site/markdown/development.md b/src/site/markdown/development.md
index f9fc082f4ed71eca9c308644e886b35c189bd297..aecd8c167fbd26b15f3bbe4fefa9dace476b837b 100644
--- a/src/site/markdown/development.md
+++ b/src/site/markdown/development.md
@@ -57,6 +57,18 @@ To package sources, you must have:
                 validationQuery="select 1" />
 ```
 
+Ensure JVM args contains in the server launch configuration:
+
+```
+--add-opens=java.base/java.lang=ALL-UNNAMED
+--add-opens=java.base/java.math=ALL-UNNAMED
+--add-opens=java.base/java.net=ALL-UNNAMED
+--add-opens=java.base/java.text=ALL-UNNAMED
+--add-opens=java.base/java.util=ALL-UNNAMED
+--add-opens=java.base/java.util.concurrent=ALL-UNNAMED
+--add-opens=java.sql/java.sql=ALL-UNNAMED
+```
+
 If CodeServer fails to launch in Eclipse, use the script
 
 ```
diff --git a/src/site/markdown/installation.md b/src/site/markdown/installation.md
index 8cf50a6256b3210a064f0f59961c4950b53cdde4..2f772c7cede0b4574bc76afedaa99dafd8d8af03 100644
--- a/src/site/markdown/installation.md
+++ b/src/site/markdown/installation.md
@@ -64,3 +64,15 @@ Define credentials to deploy, change `conf/tomcat-users.xml` with
 <role rolename="manager-script"/>
 <user username="tomcat-password" password="tomcat" roles="tomcat,manager-gui,manager-script"/>
 ```
+
+Ensure JVM args contains in the server launch configuration (in variable `JAVA_OPTS`) :
+
+```
+--add-opens=java.base/java.lang=ALL-UNNAMED
+--add-opens=java.base/java.math=ALL-UNNAMED
+--add-opens=java.base/java.net=ALL-UNNAMED
+--add-opens=java.base/java.text=ALL-UNNAMED
+--add-opens=java.base/java.util=ALL-UNNAMED
+--add-opens=java.base/java.util.concurrent=ALL-UNNAMED
+--add-opens=java.sql/java.sql=ALL-UNNAMED
+```
\ No newline at end of file
diff --git a/www-server/pom.xml b/www-server/pom.xml
index e622b46ad822c12e747a4df037efcb1cabc7831f..1369e52c7df453f60d08566b4dfd3c6f34dfd78b 100644
--- a/www-server/pom.xml
+++ b/www-server/pom.xml
@@ -155,6 +155,18 @@
       <artifactId>postgresql</artifactId>
       <version>42.7.1</version>
     </dependency>
+    <!-- fast-serialization -->
+    <!-- https://mvnrepository.com/artifact/de.ruedigermoeller/fst -->
+    <dependency>
+        <groupId>com.fasterxml.jackson.core</groupId>
+        <artifactId>jackson-core</artifactId>
+        <version>2.16.0</version>
+    </dependency>
+    <dependency>
+        <groupId>de.ruedigermoeller</groupId>
+        <artifactId>fst</artifactId>
+        <version>3.0.4-jdk17</version>
+    </dependency>
     <!-- SAVA -->
     <dependency>
       <groupId>fr.inrae.agroclim</groupId>
@@ -230,6 +242,21 @@
     </testResources>
     <pluginManagement>
       <plugins>
+        <plugin>
+          <groupId>org.apache.maven.plugins</groupId>
+          <artifactId>maven-surefire-plugin</artifactId>
+          <configuration>
+			  <argLine>
+--add-opens=java.base/java.lang=ALL-UNNAMED
+--add-opens=java.base/java.math=ALL-UNNAMED
+--add-opens=java.base/java.net=ALL-UNNAMED
+--add-opens=java.base/java.text=ALL-UNNAMED
+--add-opens=java.base/java.util=ALL-UNNAMED
+--add-opens=java.base/java.util.concurrent=ALL-UNNAMED
+--add-opens=java.sql/java.sql=ALL-UNNAMED
+			  </argLine>
+		  </configuration>
+        </plugin>
         <plugin>
           <groupId>org.apache.maven.plugins</groupId>
           <artifactId>maven-war-plugin</artifactId>
diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/AgroMetInfoConfiguration.java b/www-server/src/main/java/fr/agrometinfo/www/server/AgroMetInfoConfiguration.java
index 217c016261013af6222f7ac7c8cd0698f419121b..78b496fd3892e260d19091f9622a7c4ec269569f 100644
--- a/www-server/src/main/java/fr/agrometinfo/www/server/AgroMetInfoConfiguration.java
+++ b/www-server/src/main/java/fr/agrometinfo/www/server/AgroMetInfoConfiguration.java
@@ -1,12 +1,15 @@
 package fr.agrometinfo.www.server;
 
+import java.io.File;
 import java.util.EnumMap;
 import java.util.Map;
 import java.util.Objects;
 
 import jakarta.annotation.PostConstruct;
 import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Produces;
 import jakarta.inject.Inject;
+import jakarta.inject.Named;
 import jakarta.servlet.ServletContext;
 import lombok.Getter;
 import lombok.NonNull;
@@ -28,6 +31,10 @@ public class AgroMetInfoConfiguration {
          * The application URL.
          */
         APP_URL("app.url"),
+        /**
+         * Cache directory path.
+         */
+        CACHE_DIRECTORY("cache.directory"),
         /**
          * Target environment (dev, preprod, prod).
          */
@@ -99,6 +106,15 @@ public class AgroMetInfoConfiguration {
         return values.get(key);
     }
 
+    /**
+     * @return cache directory path
+     */
+    @Named("cacheDirectory")
+    @Produces
+    public String getCacheDirectory() {
+        return get(ConfigurationKey.CACHE_DIRECTORY);
+    }
+
     /**
      * Initialize configuration from context.xml.
      */
@@ -112,5 +128,12 @@ public class AgroMetInfoConfiguration {
             Objects.requireNonNull(value, "Key " + strKey + " must have value in context.xml");
             values.put(key, value);
         }
+        final File dir = new File(getCacheDirectory());
+        if (!dir.exists()) {
+            LOGGER.info("Creating directory {}", dir.getAbsolutePath());
+            if (!dir.mkdirs()) {
+                LOGGER.fatal("Cache directory {} failed to create!", dir.getAbsolutePath());
+            }
+        }
     }
 }
diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/dao/SimulationDao.java b/www-server/src/main/java/fr/agrometinfo/www/server/dao/SimulationDao.java
new file mode 100644
index 0000000000000000000000000000000000000000..b1dfc6cf11aac91183c1f7fe3397b0b150589f9c
--- /dev/null
+++ b/www-server/src/main/java/fr/agrometinfo/www/server/dao/SimulationDao.java
@@ -0,0 +1,23 @@
+package fr.agrometinfo.www.server.dao;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import fr.agrometinfo.www.server.model.Simulation;
+
+/**
+ * DAO for {@link Simulation}.
+ *
+ * @author Olivier Maury
+ */
+public interface SimulationDao {
+    /**
+     * @return the last simulated date
+     */
+    LocalDate findLastSimulatedDate();
+
+    /**
+     * @return date of last simulation successfully run
+     */
+    LocalDateTime findLastSimulationEnd();
+}
diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/dao/SimulationDaoHibernate.java b/www-server/src/main/java/fr/agrometinfo/www/server/dao/SimulationDaoHibernate.java
new file mode 100644
index 0000000000000000000000000000000000000000..d9d748b4ff16ba522752b6e859d1752c6b28d13c
--- /dev/null
+++ b/www-server/src/main/java/fr/agrometinfo/www/server/dao/SimulationDaoHibernate.java
@@ -0,0 +1,40 @@
+package fr.agrometinfo.www.server.dao;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import fr.agrometinfo.www.server.model.Simulation;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Produces;
+import jakarta.inject.Named;
+
+/**
+ * Hibernate implementation of {@link SimulationDao}.
+ *
+ * @author Olivier Maury
+ */
+@ApplicationScoped
+public class SimulationDaoHibernate extends DaoHibernate<Simulation> implements SimulationDao {
+
+    /**
+     * Constructor.
+     */
+    public SimulationDaoHibernate() {
+        super(Simulation.class);
+    }
+
+    @Override
+    public final LocalDate findLastSimulatedDate() {
+        final var jpql = "SELECT MAX(t.date) FROM Simulation t WHERE t.ended IS NOT NULL";
+        return super.findOneByJPQL(jpql, null, LocalDate.class);
+    }
+
+    @Named("lastModification")
+    @Produces
+    @Override
+    public final LocalDateTime findLastSimulationEnd() {
+        final var jpql = "SELECT MAX(t.ended) FROM Simulation t WHERE t.ended IS NOT NULL";
+        return super.findOneByJPQL(jpql, null, LocalDateTime.class);
+    }
+
+}
diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/model/DailyValue.java b/www-server/src/main/java/fr/agrometinfo/www/server/model/DailyValue.java
index 650b655c9ff1453e2c8b8babdf11a380ea9e57ec..3f8cf9c1aef406af5314cad7535cdb7c2c9cc80a 100644
--- a/www-server/src/main/java/fr/agrometinfo/www/server/model/DailyValue.java
+++ b/www-server/src/main/java/fr/agrometinfo/www/server/model/DailyValue.java
@@ -49,7 +49,7 @@ public class DailyValue {
     @Column(name = "date", nullable = false)
     private final LocalDate date = LocalDate.now();
     /**
-     * ID: SAFRAN cell number.
+     * PK.
      */
     @Id
     @GeneratedValue(strategy = GenerationType.AUTO)
diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/model/Simulation.java b/www-server/src/main/java/fr/agrometinfo/www/server/model/Simulation.java
new file mode 100644
index 0000000000000000000000000000000000000000..f3cb4e01a88724cbb65ba7e15b1897d00cf18cf1
--- /dev/null
+++ b/www-server/src/main/java/fr/agrometinfo/www/server/model/Simulation.java
@@ -0,0 +1,55 @@
+package fr.agrometinfo.www.server.model;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Data;
+
+/**
+ * Simulation run to produce the values.
+ *
+ * @author Olivier Maury
+ */
+@Data
+@Entity
+@Table(name = "simulation")
+public class Simulation {
+    /**
+     * Simulation start.
+     */
+    @Column(name = "started", nullable = false)
+    private LocalDateTime created;
+
+    /**
+     * Simulated date.
+     */
+    @Column(name = "date", nullable = false)
+    private LocalDate date;
+
+    /**
+     * Simulation end.
+     */
+    @Column(name = "ended", nullable = true)
+    private LocalDateTime ended;
+
+    /**
+     * PK.
+     */
+    @Id
+    @GeneratedValue(strategy = GenerationType.AUTO)
+    @Column(name = "id")
+    private long id;
+
+    /**
+     * Simulation ID.
+     */
+    @Column(name = "simulationid")
+    private long simulationId;
+
+}
diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/rs/IndicatorResource.java b/www-server/src/main/java/fr/agrometinfo/www/server/rs/IndicatorResource.java
index 706665d2a6496fbc45e43fb923509432aa91518c..ef39990f9dac35a089f276e48ab8e7ae0d66b7f3 100644
--- a/www-server/src/main/java/fr/agrometinfo/www/server/rs/IndicatorResource.java
+++ b/www-server/src/main/java/fr/agrometinfo/www/server/rs/IndicatorResource.java
@@ -22,11 +22,13 @@ import fr.agrometinfo.www.server.dao.MonthlyValueDao;
 import fr.agrometinfo.www.server.dao.PraDailyValueDao;
 import fr.agrometinfo.www.server.dao.PraDao;
 import fr.agrometinfo.www.server.dao.RegionDao;
+import fr.agrometinfo.www.server.dao.SimulationDao;
 import fr.agrometinfo.www.server.model.Indicator;
 import fr.agrometinfo.www.server.model.MonthlyValue;
 import fr.agrometinfo.www.server.model.Pra;
 import fr.agrometinfo.www.server.model.PraDailyValue;
 import fr.agrometinfo.www.server.model.Region;
+import fr.agrometinfo.www.server.service.CacheService;
 import fr.agrometinfo.www.server.util.DateUtils;
 import fr.agrometinfo.www.server.util.LocaleUtils;
 import fr.agrometinfo.www.shared.dto.ChoiceDTO;
@@ -37,7 +39,6 @@ import fr.agrometinfo.www.shared.dto.PeriodDTO;
 import fr.agrometinfo.www.shared.dto.SimpleFeature;
 import fr.agrometinfo.www.shared.dto.SummaryDTO;
 import fr.agrometinfo.www.shared.service.IndicatorService;
-import jakarta.annotation.PostConstruct;
 import jakarta.enterprise.context.RequestScoped;
 import jakarta.inject.Inject;
 import jakarta.servlet.http.HttpServletRequest;
@@ -47,7 +48,9 @@ import jakarta.ws.rs.Produces;
 import jakarta.ws.rs.QueryParam;
 import jakarta.ws.rs.WebApplicationException;
 import jakarta.ws.rs.core.Context;
+import jakarta.ws.rs.core.HttpHeaders;
 import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Request;
 import jakarta.ws.rs.core.Response;
 import lombok.extern.log4j.Log4j2;
 
@@ -112,6 +115,12 @@ public class IndicatorResource implements IndicatorService {
         return feature;
     }
 
+    /**
+     * Cache service for server-side and browser-side.
+     */
+    @Inject
+    private CacheService cacheService;
+
     /**
      * DAO for cells.
      */
@@ -154,6 +163,24 @@ public class IndicatorResource implements IndicatorService {
     @Inject
     private RegionDao regionDao;
 
+    /**
+     * JAX-RS request.
+     */
+    @Context
+    private Request request;
+
+    /**
+     * HTTP headers for response.
+     */
+    @Context
+    private HttpHeaders httpHeaders;
+
+    /**
+     * Dao for Simulation.
+     */
+    @Inject
+    private SimulationDao simulationDao;
+
     /**
      * Ensure the value of query parameter is not null and not blank.
      *
@@ -164,7 +191,7 @@ public class IndicatorResource implements IndicatorService {
     private void checkRequired(final Object value, final String queryParamName) {
         if (value instanceof final String str && str.isBlank() || value == null) {
             final var status = Response.Status.BAD_REQUEST;
-            throw new WebApplicationException(
+            throw new WebApplicationException(//
                     Response.status(status) //
                     .entity(ErrorResponseDTO.of(status.getStatusCode(), //
                             status.getReasonPhrase(), //
@@ -176,14 +203,25 @@ public class IndicatorResource implements IndicatorService {
     /**
      * @return indicator categories with their indicators
      */
+    @SuppressWarnings("unchecked")
     @GET
     @Path(IndicatorService.PATH_LIST)
     @Produces(MediaType.APPLICATION_JSON)
     @Override
     public List<PeriodDTO> getPeriods() {
-        // TODO : ajouter un cache (CacheControl, E-Tag et WebFilter)
         LOGGER.traceEntry();
         final var locale = LocaleUtils.getLocale(httpServletRequest);
+        // HTTP cache headers
+        cacheService.setCacheKey(IndicatorService.PATH, IndicatorService.PATH_LIST, locale);
+        cacheService.setHeaders(httpHeaders);
+        if (!cacheService.needsResponse(request)) {
+            return List.of();
+        }
+        // cached response
+        if (cacheService.isCached()) {
+            return (List<PeriodDTO>) cacheService.getCache();
+        }
+        //
         final var indicators = praDailyValueDao.findIndicators();
         final Map<Long, PeriodDTO> dtos = new LinkedHashMap<>();
         for (final Indicator indicator : indicators) {
@@ -205,18 +243,33 @@ public class IndicatorResource implements IndicatorService {
         }
         final List<PeriodDTO> periods = new ArrayList<>(dtos.values());
         Collections.sort(periods, (o1, o2) -> o1.getDescription().compareTo(o2.getDescription()));
+        cacheService.setCache(periods);
         return periods;
     }
 
+    @SuppressWarnings("unchecked")
     @GET
     @Path(IndicatorService.PATH_REGIONS)
     @Produces(MediaType.APPLICATION_JSON)
     @Override
     public Map<String, String> getRegions() {
-        return regionDao.findAll().stream()//
+        // HTTP cache headers
+        cacheService.setCacheKey(IndicatorService.PATH, IndicatorService.PATH_REGIONS);
+        cacheService.setHeaders(httpHeaders);
+        if (!cacheService.needsResponse(request)) {
+            return Map.of();
+        }
+        // cached response
+        if (cacheService.isCached()) {
+            return (Map<String, String>) cacheService.getCache();
+        }
+        //
+        final Map<String, String> result = regionDao.findAll().stream()//
                 .collect(LinkedHashMap::new, //
                         (map, item) -> map.put(String.valueOf(item.getId()), item.getName()), //
                         Map::putAll);
+        cacheService.setCache(result);
+        return result;
     }
 
     @GET
@@ -230,8 +283,19 @@ public class IndicatorResource implements IndicatorService {
         checkRequired(indicatorUid, "indicator");
         checkRequired(periodCode, "period");
         checkRequired(year, "year");
-
         final var locale = LocaleUtils.getLocale(httpServletRequest);
+        // HTTP cache headers
+        cacheService.setCacheKey(IndicatorService.PATH, IndicatorService.PATH_SUMMARY, locale, indicatorUid, periodCode,
+                level, id, year);
+        cacheService.setHeaders(httpHeaders);
+        if (!cacheService.needsResponse(request)) {
+            return null;
+        }
+        // cached response
+        if (cacheService.isCached()) {
+            return (SummaryDTO) cacheService.getCache();
+        }
+        //
         final var indicator = indicatorDao.findByCodeAndPeriod(indicatorUid, periodCode);
         if (indicator == null) {
             final var status = Response.Status.BAD_REQUEST;
@@ -322,6 +386,7 @@ public class IndicatorResource implements IndicatorService {
         dto.setMonthlyValues(monthlyValues);
         dto.setParentFeature(parentFeature);
         dto.setPeriod(getTranslation(indicator.getPeriod().getNames(), locale));
+        cacheService.setCache(dto);
         return dto;
     }
 
@@ -336,6 +401,18 @@ public class IndicatorResource implements IndicatorService {
         checkRequired(indicatorUid, "indicator");
         checkRequired(periodCode, "period");
         checkRequired(year, "year");
+        // HTTP cache headers
+        cacheService.setCacheKey(IndicatorService.PATH, IndicatorService.PATH_VALUES, indicatorUid, periodCode,
+                regionId, year, comparison);
+        cacheService.setHeaders(httpHeaders);
+        if (!cacheService.needsResponse(request)) {
+            return null;
+        }
+        // cached response
+        if (cacheService.isCached()) {
+            return (FeatureCollection) cacheService.getCache();
+        }
+        //
         final FeatureCollection collection = new FeatureCollection();
         final Indicator indicator = indicatorDao.findByCodeAndPeriod(indicatorUid, periodCode);
         final LocalDate date = praDailyValueDao.findLastDate(indicator, year);
@@ -361,20 +438,30 @@ public class IndicatorResource implements IndicatorService {
             .map(IndicatorResource::toFeatureWithComparedValue) //
             .forEach(collection::add);
         }
+        cacheService.setCache(collection);
         return collection;
     }
 
+    @SuppressWarnings("unchecked")
     @GET
     @Path(IndicatorService.PATH_YEARS)
     @Produces(MediaType.APPLICATION_JSON)
     @Override
     public List<Integer> getYears() {
-        return praDailyValueDao.findYears();
-    }
-
-    @PostConstruct
-    public void init() {
-        LOGGER.traceEntry();
+        // HTTP cache headers
+        cacheService.setCacheKey(IndicatorService.PATH, IndicatorService.PATH_YEARS);
+        cacheService.setHeaders(httpHeaders);
+        if (!cacheService.needsResponse(request)) {
+            return List.of();
+        }
+        // cached response
+        if (cacheService.isCached()) {
+            return (List<Integer>) cacheService.getCache();
+        }
+        //
+        final List<Integer> result = praDailyValueDao.findYears();
+        cacheService.setCache(result);
+        return result;
     }
 
     private Map<Date, Float> toMonthlyValues(final List<MonthlyValue> values) {
diff --git a/www-server/src/main/java/fr/agrometinfo/www/server/service/CacheService.java b/www-server/src/main/java/fr/agrometinfo/www/server/service/CacheService.java
new file mode 100644
index 0000000000000000000000000000000000000000..8c038d9f4ff838bcd75b4a6f8b0e589d519b326e
--- /dev/null
+++ b/www-server/src/main/java/fr/agrometinfo/www/server/service/CacheService.java
@@ -0,0 +1,200 @@
+package fr.agrometinfo.www.server.service;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Paths;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.StringJoiner;
+
+import org.nustaq.serialization.FSTConfiguration;
+import org.nustaq.serialization.FSTObjectInput;
+import org.nustaq.serialization.FSTObjectOutput;
+
+import jakarta.enterprise.context.RequestScoped;
+import jakarta.inject.Inject;
+import jakarta.inject.Named;
+import jakarta.ws.rs.core.CacheControl;
+import jakarta.ws.rs.core.EntityTag;
+import jakarta.ws.rs.core.HttpHeaders;
+import jakarta.ws.rs.core.Request;
+import jakarta.ws.rs.ext.RuntimeDelegate;
+import lombok.Setter;
+import lombok.extern.log4j.Log4j2;
+
+/**
+ * Server-side cache service and HTTP headers managements for browser-side
+ * cache.
+ *
+ * @author Olivier Maury
+ */
+@RequestScoped
+@Log4j2
+public class CacheService {
+    /**
+     * Config of FST.
+     *
+     * https://github.com/RuedigerMoeller/fast-serialization
+     *
+     * FST is 4x faster than JDK serialization for this data.
+     *
+     * FST is 5x faster than JDK deserialization for this data, so 3x faster than
+     * reading from remote database.
+     */
+    private static final FSTConfiguration FSTCONF = FSTConfiguration.createDefaultConfiguration();
+
+    /**
+     * Number of milliseconds in a day.
+     */
+    private static final long MILLISECONDS_IN_A_DAY = 60 * 60 * 24 * 1000;
+
+    /**
+     * Cache directory path.
+     */
+    @Setter
+    @Inject
+    @Named("cacheDirectory")
+    private String cacheDirectory;
+
+    /**
+     * Date of last modification of indicators values in database.
+     */
+    @Setter
+    @Inject
+    @Named("lastModification")
+    private LocalDateTime lastModification;
+
+    /**
+     * Key related to object to cache.
+     */
+    private String cacheKey;
+
+    /**
+     * Cache retention in days.
+     */
+    @Setter
+    private int nbOfDays = 1;
+
+    /**
+     * @return object from cache or null.
+     */
+    public Object getCache() {
+        final File cacheFile = getCacheFile();
+        if (!cacheFile.exists()) {
+            return null;
+        }
+        try (FileInputStream fis = new FileInputStream(cacheFile); FSTObjectInput in = new FSTObjectInput(fis)) {
+            return in.readObject();
+        } catch (final IOException | ClassNotFoundException e) {
+            LOGGER.fatal(e);
+        }
+        return null;
+    }
+
+    /**
+     * Create the HTTP Cache-Control response header according to lastModification
+     * and 1 retention day.
+     *
+     * @return cache control
+     */
+    private CacheControl getCacheControl() {
+        final CacheControl cc = new CacheControl();
+        final LocalDateTime dayAfter = lastModification.plusDays(nbOfDays);
+        final Long maxAge = ChronoUnit.SECONDS.between(LocalDateTime.now(), dayAfter);
+        // max age = time inn seconds
+        cc.setMaxAge(maxAge.intValue());
+        return cc;
+    }
+
+    private File getCacheFile() {
+        return Paths.get(cacheDirectory, cacheKey).toFile();
+    }
+
+    /**
+     * Create the HTTP Entity Tag, used as the value of an ETag response header,
+     * according to cacheKey.
+     *
+     * @return Entity Tag
+     */
+    private EntityTag getEtag() {
+        if (cacheKey == null) {
+            throw new IllegalStateException("cacheKey must be set before.");
+        }
+        return new EntityTag(Integer.toString(cacheKey.hashCode()));
+    }
+
+    /**
+     * @return if cache key as related cache on disk and cache is up to date.
+     */
+    public boolean isCached() {
+        final File cacheFile = getCacheFile();
+        return cacheFile.exists() && new Date().getTime() - cacheFile.lastModified() < nbOfDays * MILLISECONDS_IN_A_DAY;
+    }
+
+    /**
+     * @param request JAX-RS request
+     * @return if HTTP headers do not match current Entity Tag
+     */
+    public boolean needsResponse(final Request request) {
+        if (request == null) {
+            LOGGER.error("Request must not be null!");
+            return true;
+        }
+        return request.evaluatePreconditions(getEtag()) == null;
+    }
+
+    /**
+     * Store object in cache.
+     *
+     * @param object object to cache
+     */
+    public void setCache(final Object object) {
+        LOGGER.traceEntry("{}", object);
+        final File cacheFile = getCacheFile();
+        LOGGER.info("file Path = {}", cacheFile);
+        try (FileOutputStream fos = new FileOutputStream(cacheFile);
+                FSTObjectOutput out = FSTCONF.getObjectOutput(fos)) {
+            out.writeObject(object);
+            out.flush();
+        } catch (final IOException e) {
+            LOGGER.fatal(e);
+        }
+        LOGGER.traceExit("cached in {}", cacheFile);
+
+    }
+
+    /**
+     * @param objects objects to create a cache key
+     */
+    public void setCacheKey(final Object... objects) {
+        final StringJoiner sj = new StringJoiner("-");
+        for (final Object object : objects) {
+            if (object == null) {
+                sj.add("null");
+            } else {
+                sj.add(object.toString());
+            }
+        }
+        cacheKey = sj.toString();
+    }
+
+    /**
+     * Write HTTP headers to JAX-RS response.
+     *
+     * @param httpHeaders JAX-RS HTTP headers
+     */
+    public void setHeaders(final HttpHeaders httpHeaders) {
+        if (httpHeaders == null) {
+            LOGGER.error("HttpHeaders must not be null!");
+            return;
+        }
+        final var delegate = RuntimeDelegate.getInstance();
+        httpHeaders.getRequestHeaders().putSingle("Cache-Control",
+                delegate.createHeaderDelegate(CacheControl.class).toString(getCacheControl()));
+        httpHeaders.getRequestHeaders().putSingle("ETag",
+                delegate.createHeaderDelegate(EntityTag.class).toString(getEtag()));
+    }
+}
diff --git a/www-server/src/main/resources/log4j2.xml b/www-server/src/main/resources/log4j2.xml
index 8c0d7729e40aa4fe0ab312701085e4228fb2426a..816337036532ab7fb27f0fad5bb8a400ec5fe819 100644
--- a/www-server/src/main/resources/log4j2.xml
+++ b/www-server/src/main/resources/log4j2.xml
@@ -28,6 +28,7 @@
                         <AppenderRef ref="console" level="trace" />
                         <AppenderRef ref="file" level="trace" />
                 </Root>
+                <Logger name="fr.agrometinfo" level="trace" />
                 <Logger name="org.hibernate" level="warn" />
                 <Logger name="org.jboss" level="warn" />
         </Loggers>
diff --git a/www-server/src/main/tomcat10xconf/context.xml b/www-server/src/main/tomcat10xconf/context.xml
index 0125add0eb0816392f8009aa47c0fd9ad67f8627..197593d809e7121ac017bb17a6b1a607ab8d0080 100644
--- a/www-server/src/main/tomcat10xconf/context.xml
+++ b/www-server/src/main/tomcat10xconf/context.xml
@@ -3,6 +3,7 @@
 <Context path="/www-server" reloadable="true">
   <Parameter name="agrometinfo.app.email" value="agrometinfoXXXX@inrae.fr" />
   <Parameter name="agrometinfo.app.url" value="http://localhost:8080/www-server/" />
+  <Parameter name="agrometinfo.cache.directory" value="/tmp/agrometinfo/" />
   <Parameter name="agrometinfo.environment" value="dev" /> <!-- dev / preprod / prod -->
   <Parameter name="agrometinfo.log.email" value="agrometinfoXXXX@inrae.fr" />
   <Parameter name="agrometinfo.smtp.host" value="smtp.inrae.fr" />
diff --git a/www-server/src/test/java/fr/agrometinfo/www/server/dao/SimulationDaoHibernateTest.java b/www-server/src/test/java/fr/agrometinfo/www/server/dao/SimulationDaoHibernateTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..976a0826662e494ae506b4ff5607ef16cc0be791
--- /dev/null
+++ b/www-server/src/test/java/fr/agrometinfo/www/server/dao/SimulationDaoHibernateTest.java
@@ -0,0 +1,43 @@
+package fr.agrometinfo.www.server.dao;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Test SimulationDao Hibernate implementation.
+ */
+public class SimulationDaoHibernateTest {
+    /**
+     * Last modification set in SQL.
+     */
+    public static final LocalDateTime LAST_MODIFICATION = LocalDateTime.parse("2024-02-20T12:30:00");
+
+    /**
+     * DAO to test.
+     */
+    private final SimulationDao dao = new SimulationDaoHibernate();
+
+    /**
+     * Ensure reading is OK.
+     */
+    @Test
+    void findLastSimulatedDate() {
+        final var actual = dao.findLastSimulatedDate();
+        final var expected = LocalDate.parse("2024-02-19");
+        assertEquals(expected, actual);
+    }
+
+    /**
+     * Ensure reading is OK.
+     */
+    @Test
+    void findLastSimulationEnd() {
+        final var actual = dao.findLastSimulationEnd();
+        final var expected = LAST_MODIFICATION;
+        assertEquals(expected, actual);
+    }
+}
diff --git a/www-server/src/test/java/fr/agrometinfo/www/server/rs/IndicatorResourceTest.java b/www-server/src/test/java/fr/agrometinfo/www/server/rs/IndicatorResourceTest.java
index 34ae4ece9aeeb948a5989136de0927068de6e8c4..05469d6ae75f5ae5b546b3b8036c0c33e66952c6 100644
--- a/www-server/src/test/java/fr/agrometinfo/www/server/rs/IndicatorResourceTest.java
+++ b/www-server/src/test/java/fr/agrometinfo/www/server/rs/IndicatorResourceTest.java
@@ -3,10 +3,18 @@ package fr.agrometinfo.www.server.rs;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Comparator;
+
 import org.geojson.FeatureCollection;
 import org.glassfish.hk2.utilities.binding.AbstractBinder;
 import org.glassfish.jersey.server.ResourceConfig;
 import org.glassfish.jersey.test.JerseyTest;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 
 import fr.agrometinfo.www.server.dao.CellDao;
@@ -21,6 +29,10 @@ import fr.agrometinfo.www.server.dao.PraDao;
 import fr.agrometinfo.www.server.dao.PraDaoHibernate;
 import fr.agrometinfo.www.server.dao.RegionDao;
 import fr.agrometinfo.www.server.dao.RegionDaoHibernate;
+import fr.agrometinfo.www.server.dao.SimulationDao;
+import fr.agrometinfo.www.server.dao.SimulationDaoHibernate;
+import fr.agrometinfo.www.server.dao.SimulationDaoHibernateTest;
+import fr.agrometinfo.www.server.service.CacheService;
 import jakarta.ws.rs.core.Application;
 
 /**
@@ -34,6 +46,24 @@ class IndicatorResourceTest extends JerseyTest {
      */
     private static final String SEP = "/";
 
+    /**
+     * Temporary directory for cache.
+     */
+    private static Path cacheDir;
+
+    @BeforeAll
+    static void createCacheDir() throws IOException {
+        cacheDir = Files.createTempDirectory(IndicatorResourceTest.class.getName());
+    }
+
+    @AfterAll
+    static void deleteCacheDir() throws IOException {
+        Files.walk(cacheDir)
+        .sorted(Comparator.reverseOrder())
+        .map(Path::toFile)
+        .forEach(File::delete);
+    }
+
     @Override
     protected final Application configure() {
         final CellDao cellDao = new CellDaoHibernate();
@@ -42,6 +72,10 @@ class IndicatorResourceTest extends JerseyTest {
         final IndicatorDao indicatorDao = new IndicatorDaoHibernate();
         final MonthlyValueDao monthlyValueDao = new MonthlyValueDaoHibernate();
         final RegionDao regionDao = new RegionDaoHibernate();
+        final SimulationDao simulationDao = new SimulationDaoHibernate();
+        final CacheService cacheService = new CacheService();
+        cacheService.setLastModification(SimulationDaoHibernateTest.LAST_MODIFICATION);
+        cacheService.setCacheDirectory(cacheDir.toString());
         return new ResourceConfig(IndicatorResource.class).register(new AbstractBinder() {
             @Override
             public void configure() {
@@ -51,6 +85,8 @@ class IndicatorResourceTest extends JerseyTest {
                 bind(indicatorDao).to(IndicatorDao.class);
                 bind(monthlyValueDao).to(MonthlyValueDao.class);
                 bind(regionDao).to(RegionDao.class);
+                bind(simulationDao).to(SimulationDao.class);
+                bind(cacheService).to(CacheService.class);
             }
         });
     }
diff --git a/www-server/src/test/resources/META-INF/persistence.xml b/www-server/src/test/resources/META-INF/persistence.xml
index 1de816efe743696eb7234330e8e5c387b5d8a358..74353e4ccf49232e47ee293e7d1de0e1c3351f87 100644
--- a/www-server/src/test/resources/META-INF/persistence.xml
+++ b/www-server/src/test/resources/META-INF/persistence.xml
@@ -16,6 +16,7 @@
     <class>fr.agrometinfo.www.server.model.Pra</class>
     <class>fr.agrometinfo.www.server.model.PraDailyValue</class>
     <class>fr.agrometinfo.www.server.model.Region</class>
+    <class>fr.agrometinfo.www.server.model.Simulation</class>
     <properties>
       <property name="jakarta.persistence.jdbc.url" value="jdbc:h2:mem:agrometinfo;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;INIT=RUNSCRIPT FROM '../sql/schema.types.h2.sql'\;RUNSCRIPT FROM '../sql/schema.tables.sql'\;RUNSCRIPT FROM '../sql/init_data.h2.sql';" />
       <property name="jakarta.persistence.jdbc.driver" value="org.h2.Driver" />
diff --git a/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/ChoiceDTO.java b/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/ChoiceDTO.java
index b35993c6ec5882e6c3f94b7edcce15e49bb562e0..b93db590f11ef3c302f94a6063e7366fa6c66b1b 100644
--- a/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/ChoiceDTO.java
+++ b/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/ChoiceDTO.java
@@ -1,5 +1,7 @@
 package fr.agrometinfo.www.shared.dto;
 
+import java.io.Serializable;
+
 import org.dominokit.jackson.annotation.JSONMapper;
 
 import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -10,7 +12,11 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
  * @author Olivier Maury
  */
 @JSONMapper
-public final class ChoiceDTO {
+public final class ChoiceDTO implements Serializable {
+    /**
+     * UID for Serializable.
+     */
+    private static final long serialVersionUID = 5820780438006585101L;
 
     /**
      * The user wants to compare with normal.
diff --git a/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/IndicatorDTO.java b/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/IndicatorDTO.java
index ba0502561914b4723eb5262010c6424d170d7493..1fe02e5f57002318b6be72cc93362731d469b88b 100644
--- a/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/IndicatorDTO.java
+++ b/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/IndicatorDTO.java
@@ -1,5 +1,7 @@
 package fr.agrometinfo.www.shared.dto;
 
+import java.io.Serializable;
+
 import org.dominokit.jackson.annotation.JSONMapper;
 
 /**
@@ -8,7 +10,11 @@ import org.dominokit.jackson.annotation.JSONMapper;
  * @author Olivier Maury
  */
 @JSONMapper
-public class IndicatorDTO {
+public class IndicatorDTO implements Serializable {
+    /**
+     * UID for Serializable.
+     */
+    private static final long serialVersionUID = 5820780438006585102L;
     /**
      * Localized description.
      */
diff --git a/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/PeriodDTO.java b/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/PeriodDTO.java
index 3f60f352a8c8a5fc2e3343c55cf15541b4721be8..89af772b80ab003f7105b7bc705dcb82e1fc1755 100644
--- a/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/PeriodDTO.java
+++ b/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/PeriodDTO.java
@@ -11,6 +11,10 @@ import org.dominokit.jackson.annotation.JSONMapper;
  */
 @JSONMapper
 public final class PeriodDTO extends IndicatorDTO {
+    /**
+     * UID for Serializable.
+     */
+    private static final long serialVersionUID = 5820780438006585103L;
     /**
      * The indicators related to this period.
      */
diff --git a/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/SimpleFeature.java b/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/SimpleFeature.java
index 0f7d4b37a33dfca5a8303663fa6530a1f73cee2c..054c139ec22fdd495ee80807111ce23c73e5292f 100644
--- a/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/SimpleFeature.java
+++ b/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/SimpleFeature.java
@@ -1,11 +1,17 @@
 package fr.agrometinfo.www.shared.dto;
 
+import java.io.Serializable;
+
 /**
  * A geographic object.
  *
  * @author Olivier Maury
  */
-public class SimpleFeature {
+public class SimpleFeature implements Serializable {
+    /**
+     * UID for Serializable.
+     */
+    private static final long serialVersionUID = 5820780438006585104L;
     /**
      * Unique identifier.
      */
diff --git a/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/SummaryDTO.java b/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/SummaryDTO.java
index e48ed9c463f91bdab521bf6a1ed943e70b164582..ebdb8de76d84d2f257ea54c26a1b2d83e339cfda 100644
--- a/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/SummaryDTO.java
+++ b/www-shared/src/main/java/fr/agrometinfo/www/shared/dto/SummaryDTO.java
@@ -1,5 +1,6 @@
 package fr.agrometinfo.www.shared.dto;
 
+import java.io.Serializable;
 import java.util.Date;
 import java.util.Map;
 
@@ -11,7 +12,11 @@ import org.dominokit.jackson.annotation.JSONMapper;
  * @author Olivier Maury
  */
 @JSONMapper
-public class SummaryDTO {
+public class SummaryDTO implements Serializable {
+    /**
+     * UID for Serializable.
+     */
+    private static final long serialVersionUID = 5820780438006585105L;
     /**
      * Average daily value of the indicator for the user choice.
      */
diff --git a/www-shared/src/main/java/org/geojson/Feature.java b/www-shared/src/main/java/org/geojson/Feature.java
index 227de207499889494bd4a73289fc650161bd4ee9..c0b0e3f539c807a7b91aa8c592c404dfc71c07b4 100644
--- a/www-shared/src/main/java/org/geojson/Feature.java
+++ b/www-shared/src/main/java/org/geojson/Feature.java
@@ -12,6 +12,10 @@ import org.dominokit.jackson.annotation.JSONMapper;
  */
 @JSONMapper
 public final class Feature extends GeoJsonObject {
+    /**
+     * UID for Serializable.
+     */
+    private static final long serialVersionUID = 5820780438006585201L;
 
     /**
      * Associated properties.
diff --git a/www-shared/src/main/java/org/geojson/FeatureCollection.java b/www-shared/src/main/java/org/geojson/FeatureCollection.java
index 702e0d5762fc1141424b0df546c63d7f3d766d94..8527be6fa170ec2d849ef242354d80ebbbf02f37 100644
--- a/www-shared/src/main/java/org/geojson/FeatureCollection.java
+++ b/www-shared/src/main/java/org/geojson/FeatureCollection.java
@@ -13,6 +13,11 @@ import org.dominokit.jackson.annotation.JSONMapper;
  */
 @JSONMapper
 public final class FeatureCollection extends GeoJsonObject {
+    /**
+     * UID for Serializable.
+     */
+    private static final long serialVersionUID = 5820780438006585202L;
+
     /**
      * The Features within this FeatureCollection.
      */
diff --git a/www-shared/src/main/java/org/geojson/GeoJsonObject.java b/www-shared/src/main/java/org/geojson/GeoJsonObject.java
index 288a6d277e8843d6215ac57a0b5b22c6efe9b2f8..8f4a8d597fc557c34e177f7ef526c79974af1da0 100644
--- a/www-shared/src/main/java/org/geojson/GeoJsonObject.java
+++ b/www-shared/src/main/java/org/geojson/GeoJsonObject.java
@@ -1,11 +1,17 @@
 package org.geojson;
 
+import java.io.Serializable;
+
 /**
  * Base class.
  *
  * @author Olivier Maury
  */
-public abstract class GeoJsonObject {
+public abstract class GeoJsonObject implements Serializable {
+    /**
+     * UID for Serializable.
+     */
+    private static final long serialVersionUID = 5820780438006585203L;
     /**
      * GeoJSON object type ("Feature", "Polygon", ...).
      */
diff --git a/www-shared/src/main/java/org/geojson/LngLatAlt.java b/www-shared/src/main/java/org/geojson/LngLatAlt.java
index 64f4ac2ebe0dd762bd5c473bafc70e27c99b207d..1fe9e5b9c8522796c45dfc35f2808195e2771f74 100644
--- a/www-shared/src/main/java/org/geojson/LngLatAlt.java
+++ b/www-shared/src/main/java/org/geojson/LngLatAlt.java
@@ -1,5 +1,7 @@
 package org.geojson;
 
+import java.io.Serializable;
+
 import org.dominokit.jackson.annotation.JSONMapper;
 
 /**
@@ -10,7 +12,11 @@ import org.dominokit.jackson.annotation.JSONMapper;
  * @author Olivier Maury
  */
 @JSONMapper
-public final class LngLatAlt {
+public final class LngLatAlt implements Serializable {
+    /**
+     * UID for Serializable.
+     */
+    private static final long serialVersionUID = 5820780438006585204L;
 
     /**
      * "X" according to the specification, longitude in a geographic CRS.
diff --git a/www-shared/src/main/java/org/geojson/MultiPolygon.java b/www-shared/src/main/java/org/geojson/MultiPolygon.java
index 5deb3b6d9cc3b7e287e4b7bcaed158f405242acc..f4423845ec45ef1ebe11ac24b82ba588f02d34d6 100644
--- a/www-shared/src/main/java/org/geojson/MultiPolygon.java
+++ b/www-shared/src/main/java/org/geojson/MultiPolygon.java
@@ -12,6 +12,11 @@ import org.dominokit.jackson.annotation.JSONMapper;
  */
 @JSONMapper
 public class MultiPolygon extends GeoJsonObject {
+    /**
+     * UID for Serializable.
+     */
+    private static final long serialVersionUID = 5820780438006585205L;
+
     /**
      * GeoJSON object type.
      */
diff --git a/www-shared/src/main/java/org/geojson/Polygon.java b/www-shared/src/main/java/org/geojson/Polygon.java
index 673f26590a99bddee1bd3573fbb2529f863eb57b..e2c73e59b98363948bd1d2f118045113ca637423 100644
--- a/www-shared/src/main/java/org/geojson/Polygon.java
+++ b/www-shared/src/main/java/org/geojson/Polygon.java
@@ -13,6 +13,11 @@ import org.dominokit.jackson.annotation.JSONMapper;
  */
 @JSONMapper
 public final class Polygon extends GeoJsonObject {
+    /**
+     * UID for Serializable.
+     */
+    private static final long serialVersionUID = 5820780438006585206L;
+
     /**
      * GeoJSON object type.
      */